Repository navigation
feat(runtime): standalone runtime binary with stdio transport - #769
Conversation
Adds an NDJSON frame port and a `mangostudio-runtime` CLI entry so the runtime can execute out of process. One JSON record per line in both directions, with the shared codec enforcing the line cap and buffering records torn across chunk boundaries. A record the codec refuses tears the connection down rather than being skipped: once the stream is desynchronised every later frame is a guess, and a guess about a filesystem mutation is worse than a failure. stdout is the protocol stream, so the stdio mode routes the stdout console methods to stderr — one stray log line would otherwise inject a record the peer has to reject. Both spellings are accepted for every mode (`--stdio` and `stdio`), leaving room for the dial-out and listen subcommands the remote transports add without changing the flag the hub passes today. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Registers the stdio transport so an environment can point at a runtime process instead of the embedded one. The launcher builds argv from discrete arguments, hands the child a sanitized environment with no connector keys or auth secret, and keeps a bounded tail of its stderr so a failed start explains itself. Reconnection is a deadline rather than a timer. A runtime that dies now reads as disconnected instead of broken — the target is usually still there — and the next caller that needs it pays for the retry, backing off exponentially and latching after five consecutive failures. Nothing is scheduled, so a disabled, deleted, or simply unused environment never respawns on its own and shutdown has no callback to forget. A protocol mismatch latches immediately: a stale binary cannot fix itself by being asked again. Shutdown closes every live connection, so runtime children are reaped with the hub rather than orphaned, and `doctor` reports whether the sibling binary is present and built from the same release. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Two places treated the hub process's working directory as a filesystem fact about the environment. That happened to hold while every runtime was embedded; a spawned one need not share the hub's filesystem at all, and a directory that does not exist there fails the call for the wrong reason. `glob` now refuses a search it cannot anchor instead of falling back to `process.cwd()`, matching what every other filesystem tool already does with a relative path and no bound workdir. `isGitAvailable` drops its `git --version` retry. The retry ran only when the handshake had already failed, and it travelled through that same connection — so it could not succeed where the manifest read had not, and it needed a hub-side cwd to make the attempt at all. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
The hub resolves `mangostudio-runtime` as a sibling of its own executable, so a release that ships one without the other silently cannot start stdio environments. Every channel now carries both: platform archives, npm platform packages, and the Docker images. Both binaries take the same build stamp, which matters because the protocol handshake refuses a version mismatch — a drifted pair fails at connect rather than misbehaving. The runtime reads its version through a literal `process.env` access, since that is the only form the compile step substitutes; reading it through an injected record silently shipped `dev` in the binary, which the smoke test caught. Cost on linux-x64: the runtime compiles in ~0.6s alongside the hub's ~0.7s, so wall-clock compile time is unchanged, but the artifact adds 95 MB uncompressed and ~5s of gzip per platform archive — that lands on the distribution+smoke critical path, roughly +40s across the matrix. The smoke test now runs the binary the way the hub does: `--version` must match the release, and `--stdio` must answer with a handshake frame carrying a capability manifest. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Ships the Add Environment dialog the entity work deferred until a transport existed to create. It offers the one kind that can actually connect today — the transports later plans add stay out until they can. An identifier is derived from the name so the common path is one field, and stops tracking it the moment someone types their own. Both advanced overrides are omitted from the request when blank rather than sent empty: an empty binary path is a path the launcher would try to spawn. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Records the two facts that are easy to get wrong from the code alone: the runtime child's stdout is protocol-only, and reconnection is a backoff deadline checked on next use rather than a scheduled retry. Also states that the two binaries are one unit — every distribution channel ships both, because the hub resolves the runtime as a sibling of its own executable and the handshake refuses a version mismatch. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
`spawnRuntimeChild` (was `launchStdioRuntime`) now receives an argv the caller resolved rather than a stdio-shaped config, so a launcher that reaches its target through a wrapper — a WSL distro, an SSH host — supplies its own command and reuses the spawn, handshake, stderr capture, and teardown unchanged instead of copying them. Alongside it, three smaller cleanups: - `environmentConfigFor` calls `assertEnvironmentConfig` instead of repeating its check and error message. - `disconnect` and `closeAll` share one teardown, so the only difference between them is the publish they do or skip. - `#markUnavailable` reads the latched deadline instead of re-deriving the attempt cap, which would drift the moment something else latches. The frame decoder also stops measuring its pending buffer exactly on every chunk. UTF-8 is never shorter than a string's UTF-16 length, so a cheap bound catches an over-limit record while it accumulates; the exact check still runs when the record completes. Before this, one large frame split across many reads re-encoded everything received so far on each one. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
|
PR head: Commits — 14 commitsBase
Full commit messages
|
| Metric | Base | Head | Δ |
|---|---|---|---|
| Wall clock (shared jobs) | 6m 40s | 6m 27s | 🟢 ▼ -13s |
| Critical path | Test / Unit & Integration Tests · 5m 12s |
Test / Unit & Integration Tests · 5m 7s |
🟢 ▼ -5s |
Since previous PR run: n/a — no previous successful CI run found for this pull request
Five slowest head jobs
| Job | Base | Head | Δ |
|---|---|---|---|
Test / Unit & Integration Tests |
5m 12s | 5m 7s | 🟢 ▼ -5s |
Distribution / Build immutable distribution |
1m 57s | 2m 39s | 🔴 ▲ +42s |
QA Metrics / Collect |
1m 11s | 1m 4s | 🟢 ▼ -7s |
Smoke — Browser / Chromium smoke suite |
52s | 1m 1s | 🔴 ▲ +9s |
Check / Check (tooling + typecheck) |
1m 4s | 59s | 🟢 ▼ -5s |
QA Gate — Coverage & Quality
Base: 266c9a0 • Head: 17ca425 • generated 2026-07-31T11:23:33.449Z
✅ No attention signals — collected metrics look healthy against base.
LoC (code): 🔴 ▲ +1735 • Line coverage (all workspaces): 🟢 ▲ +0.04pp • Quick check: pass • Duplication: 🔴 ▲ +0pp • Bundle gzip: 🔴 ▲ +1.2 KiB • Locked deps: ⚪ ▲ = 0 • Tests passed: 🟢 ▲ +50
Metric details (coverage, LoC, bundle, dependencies, tests, duplication, tooling)
Coverage
API/shared/runtime branches and statements are source-derived from LCOV line hits because Bun LCOV does not emit branch or statement records.
| Workspace | Metric | Base | Head | Δ |
|---|---|---|---|---|
| frontend | lines | 87.90% (10,465/11,905) | 87.99% (10,555/11,996) | 🟢 ▲ +0.09pp |
| frontend | statements | 77.43% (6,248/8,069) | 77.53% (6,295/8,119) | 🟢 ▲ +0.10pp |
| frontend | functions | 74.05% (1,938/2,617) | 74.08% (1,949/2,631) | 🟢 ▲ +0.03pp |
| frontend | branches | 68.40% (4,207/6,151) | 68.51% (4,234/6,180) | 🟢 ▲ +0.11pp |
| api | lines | 91.92% (52,805/57,445) | 92.00% (53,224/57,853) | 🟢 ▲ +0.08pp |
| api | statements | 90.77% (20,975/23,108) | 90.92% (21,228/23,349) | 🟢 ▲ +0.15pp |
| api | functions | 91.58% (5,418/5,916) | 91.67% (5,470/5,967) | 🟢 ▲ +0.09pp |
| api | branches | 57.57% (7,889/13,704) | 57.65% (7,978/13,838) | 🟢 ▲ +0.08pp |
| shared | lines | 99.66% (9,402/9,434) | 99.66% (9,451/9,483) | ⚪ ▲ = 0 |
| shared | statements | 98.20% (1,310/1,334) | 98.21% (1,314/1,338) | 🟢 ▲ +0.01pp |
| shared | functions | 97.67% (210/215) | 97.69% (211/216) | 🟢 ▲ +0.02pp |
| shared | branches | 66.86% (341/510) | 66.80% (342/512) | 🔴 ▼ -0.06pp |
| runtime | lines | 63.02% (2,023/3,210) | 63.72% (2,150/3,374) | 🟢 ▲ +0.70pp |
| runtime | statements | 64.99% (1,151/1,771) | 66.10% (1,250/1,891) | 🟢 ▲ +1.11pp |
| runtime | functions | 74.47% (245/329) | 75.50% (265/351) | 🟢 ▲ +1.03pp |
| runtime | branches | 36.95% (310/839) | 38.19% (338/885) | 🟢 ▲ +1.24pp |
Lines of Code
| Workspace | Base | Head | Δ |
|---|---|---|---|
| frontend | 499 files / 53,787 lines | 501 files / 54,091 lines | files 🔴 ▲ +2 • code 🔴 ▲ +304 |
| api | 859 files / 116,185 lines | 864 files / 117,046 lines | files 🔴 ▲ +5 • code 🔴 ▲ +861 |
| shared | 137 files / 15,954 lines | 137 files / 16,009 lines | files ⚪ ▲ = 0 • code 🔴 ▲ +55 |
| runtime | 44 files / 5,579 lines | 48 files / 6,094 lines | files 🔴 ▲ +4 • code 🔴 ▲ +515 |
| total | 1,539 files / 191,505 lines | 1,550 files / 193,240 lines | files 🔴 ▲ +11 • code 🔴 ▲ +1735 |
Frontend Bundle
| Metric | Base | Head | Δ |
|---|---|---|---|
| gzip total | 749.4 KiB | 750.6 KiB | 🔴 ▲ +1.2 KiB |
| gzip JavaScript | 733.8 KiB | 735.1 KiB | 🔴 ▲ +1.2 KiB |
| gzip CSS | 14.6 KiB | 14.6 KiB | ⚪ ▲ = 0 |
| gzip HTML | 946 B | 943 B | 🟢 ▼ -3 B |
| tracked files | 35 | 35 | ⚪ ▲ = 0 |
Dependencies
| Metric | Base | Head | Δ |
|---|---|---|---|
| locked packages | 858 | 858 | ⚪ ▲ = 0 |
| direct dependencies | 40 | 40 | ⚪ ▲ = 0 |
| direct devDependencies | 41 | 41 | ⚪ ▲ = 0 |
| workspace manifests | 5 | 5 | ⚪ ▲ = 0 |
Tests
Single full-suite pass (unit + integration, from the coverage run).
| Base | Head | Δ passed |
|---|---|---|
| 4,041 passed (root 4,041 / frontend 0 / api 0 / shared 0 / runtime 0) · exit 0 · 290s | 4,091 passed (root 4,091 / frontend 0 / api 0 / shared 0 / runtime 0) · exit 0 · 275s | 🟢 ▲ +50 |
Code Duplication (jscpd)
| Metric | Base | Head | Δ |
|---|---|---|---|
| clones | 826 | 836 | 🔴 ▲ +10 |
| duplicated lines | 9,427 | 9,530 | 🔴 ▲ +103 |
| percentage | 4.13% | 4.13% | 🔴 ▲ +0pp |
Repo Tooling
| Metric | Base | Head | Δ |
|---|---|---|---|
| Full repo check | pass | pass | ⚪ ▲ = 0 |
| TS errors (total) | 0 | 0 | ⚪ ▲ = 0 |
| Circular dependencies | 0 | 0 | ⚪ ▲ = 0 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff359d91a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Summary by CodeRabbit
WalkthroughThis PR adds the Sequence Diagram(s)sequenceDiagram
participant User
participant EnvironmentDialog
participant API
participant RuntimeConnectionManager
participant mangostudio-runtime
User->>EnvironmentDialog: configure and submit environment
EnvironmentDialog->>API: create stdio environment
API->>RuntimeConnectionManager: connect to environment
RuntimeConnectionManager->>mangostudio-runtime: spawn with --stdio
mangostudio-runtime-->>RuntimeConnectionManager: return protocol handshake
RuntimeConnectionManager-->>API: expose runtime connection
Possibly related PRs
Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (2)
apps/frontend/src/features/environments/components/EnvironmentEntitiesOverview.tsx (1)
27-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRestore focus to the trigger button when the dialog closes.
No ref tracks the "Add environment" button, so keyboard focus is not returned to it when
AddEnvironmentDialogcloses. Keyboard and screen-reader users lose their place in the page after closing the dialog.Store a ref to the trigger button and focus it in
onClose.♻️ Proposed refactor
+ const triggerRef = useRef<HTMLButtonElement>(null); const [adding, setAdding] = useState(false); + + const closeDialog = () => { + setAdding(false); + triggerRef.current?.focus(); + }; return ( ... <button type="button" + ref={triggerRef} onClick={() => setAdding(true)} className="..." > <Plus size={13} /> {t.environments.entities.add.trigger} </button> </div> - {adding ? <AddEnvironmentDialog onClose={() => setAdding(false)} /> : null} + {adding ? <AddEnvironmentDialog onClose={closeDialog} /> : null}apps/runtime/src/transports/stdio.ts (1)
107-114: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winContain listener exceptions inside
#deliver.A synchronous
onFramelistener can throw from the input streamdatahandler. Catch each listener exception and tear down the port withprotocol-errorso the runtime reports closure instead of raising an uncaught exception.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c466dae6-6af3-4ea7-9b5b-ccd3cde47f50
📒 Files selected for processing (56)
DockerfileDockerfile.alpineapps/api/src/cli/commands/doctor.tsapps/api/src/cli/doctor-checks.tsapps/api/src/cli/runtime-binary-probe.tsapps/api/src/lib/bounded-tail.tsapps/api/src/lib/runtime-paths.tsapps/api/src/modules/environments/application/environment-service.tsapps/api/src/modules/environments/domain/environment-config.tsapps/api/src/modules/git/infrastructure/git-cli.tsapps/api/src/server/start-server.tsapps/api/src/services/providers/core/node-sidecar/spawn-sidecar.tsapps/api/src/services/providers/cursor/sidecar-process.tsapps/api/src/services/runtime-client/runtime-connection-manager.tsapps/api/src/services/runtime-client/spawn-runtime-child.tsapps/api/src/services/tools/builtin/glob.tsapps/api/tests/integration/services/spawn-runtime-child.integration.test.tsapps/api/tests/unit/cli/commands/doctor.test.tsapps/api/tests/unit/cli/doctor-checks.test.tsapps/api/tests/unit/services/runtime-connection-manager.test.tsapps/api/tests/unit/services/tools/glob-tool.test.tsapps/frontend/src/features/environments/components/AddEnvironmentDialog.tsxapps/frontend/src/features/environments/components/EnvironmentEntitiesOverview.tsxapps/frontend/src/features/environments/queries.tsapps/frontend/tests/unit/features/environments/add-environment-dialog.test.tsxapps/runtime/AGENTS.mdapps/runtime/src/cli.tsapps/runtime/src/config.tsapps/runtime/src/index.tsapps/runtime/src/transports/stdio.tsapps/runtime/tests/unit/cli.test.tsapps/runtime/tests/unit/transports/stdio.test.tsapps/shared/src/i18n/en.tsapps/shared/src/i18n/pt-BR.tsapps/shared/src/runtime-protocol/codec.tsapps/shared/tests/unit/runtime-protocol.test.tsdocs/architecture/hub-runtime.mddocs/reference/agent-playbooks.mddocs/reference/cli.mddocs/reference/releasing.mdknip.jsonscripts/build.tsscripts/install/install.shscripts/lib/docker-stage.tsscripts/lib/npm-pack.tsscripts/lib/npm-package-validation.tsscripts/lib/release-assets.tsscripts/lib/release-targets.tsscripts/release/archive-assets.tsscripts/release/pack-npm.tsscripts/release/stage-docker-ctx.tsscripts/test-build.tsscripts/tests/archive-assets.unit.test.tsscripts/tests/npm-pack.unit.test.tsscripts/tests/npm-package-validation.unit.test.tsscripts/tests/release-assets.unit.test.ts
|
Reviews (1): Last reviewed commit: "refactor(runtime): take a resolved comma..." | Re-trigger Greptile |
The runtime was added to the release archives without being added to the manifest's `archiveMembers`, which `extract-target.ts` asserts the extracted layout equals exactly. Every target archive therefore carried an entry the consumer refused, failing all ten binary smoke jobs before they materialized a single binary. Both lists now come from one `platformArchiveLayout` helper: the archive scripts take the members relative to the build dir, the manifest takes those plus the README that is archived from outside it. A future member cannot reach one list without the other. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
stdout carries NDJSON frames, so anything else written there desynchronises the hub's decoder and takes the connection down. Only `log`, `info` and `debug` were redirected, leaving `dir`, `table`, `group`, `count`, `timeEnd` and Bun's own `write` still aimed at it. Rather than enumerate them onto `error` — which would flatten the stateful ones — the global console now takes its methods from a Console whose stream pair is stderr on both sides, so each keeps its own behaviour. `write` is replaced separately: it is Bun's, not part of the node Console surface. The handshake test inherits stderr instead of piping it, since nothing read that pipe and a runtime logging past its buffer would block on the write. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Every `error` event on the input stream was classified `protocol-error`, but EPIPE, ECONNRESET and EIO on the read side mean the peer went away — the same thing the write side already reads as EOF. The classification is observable: it decides the runtime's exit code and the reason the hub logs for the disconnect, so a peer that simply died was reported as a protocol violation. `protocol-error` now covers only records the codec refuses, which is where a desynchronised stream actually shows up. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
`Bun.spawn` resolves the executable before it returns, so a sibling path that exists but cannot be run — a directory, a file that lost its executable bit, a foreign binary format — throws synchronously. That call sat outside the guarded block, so `mango doctor` died on exactly the broken install it exists to describe, rather than returning the probe's own `error` finding. The probe now takes its path as a defaulted parameter so the cases can be tested against real files instead of a resolver that returns null in a source checkout. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Two ways the form could refuse to submit without saying why. `suggestId` trimmed dashes before slicing to the length limit, so a cut landing after an internal dash left a trailing one — which the id pattern rejects, on a field the user never touched. And `nameRequired` was defined in both locales but never rendered, so a name of only whitespace just left submit greyed out. The id is trimmed again after the slice, and the name field reports the message the same way the id field already reported `idInvalid`. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
…ilure Three ways the stdio launcher misread what went wrong. The handshake compared only the protocol version, which moves when the wire format does — so a runtime left behind by an older install handshook cleanly and was trusted for method semantics it no longer shared. The client gains `requireMatchingRelease`, set by the stdio launcher because the hub and the runtime ship as one distribution; other transports may not want that rule. A missing or unreadable `cwd` fails the spawn with the same ENOENT and EACCES a bad executable does, so a mistyped working directory told the user to reinstall MangoStudio. It is now blamed first when it is the thing missing. `terminate` resolves once the child is actually gone, so a caller can wait for it rather than exiting mid-kill; bounded, so an unkillable child delays a shutdown instead of holding it open. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
A successful connect cleared the failure count, but a handshake only shows the runtime started. One that died straight after each start recorded failure 1 every time, so it never reached the attempt cap and respawned for as long as callers kept asking. The count is now cleared by `#markUnavailable` when the connection actually lasted, not by the handshake that opened it. Two related holes: re-enabling an environment did not clear the failures its calls collected while it was disabled, which could already have latched it until someone pressed Connect; and shutdown closed connections without waiting, so the hub could exit before the SIGKILL escalation ran and leave a runtime that ignored SIGTERM orphaned. The backoff comment claimed a 16s window that the attempt cap makes unreachable — the ladder is 1s, 2s, 4s, 8s, then latch. Signed-off-by: Julio Polycarpo <julio@polycarpo.dev>
Summary
Chat tools, workspaces, and Git can run in a separate
mangostudio-runtimeprocess connected over NDJSON on stdin/stdout — the first execution target
the hub does not share memory with. Stdio environments spawn that binary
(sibling of the hub executable by default), handshake on a shared release
version, and reconnect on demand with exponential backoff instead of a
background timer. The Environments overview gains an Add Environment flow
for the stdio kind. Every distribution channel (platform archives, npm
platform packages, Docker images) now ships both binaries from one build
stamp;
mango doctorwarns when the sibling is missing or version-mismatched.Changes
apps/runtime/src/cli.ts(--stdio,--version,--help) andtransports/stdio.ts; route all console writers to stderr so stdout staysprotocol-only; classify broken input pipes as hangup, not protocol errors.
spawn-runtime-child, stdio launcher (sanitized env, argv fromdiscrete args, bounded stderr tail), and connection-manager reconnection
(latch after five failures or protocol/release mismatch;
requireMatchingReleaseon stdio connects).waiting for child termination; shared teardown for disconnect/closeAll.
AddEnvironmentDialogwith i18n (EN + pt-BR); omit blank optionalfields; fix derived-id trimming and name-required validation.
globfrom falling back to hubprocess.cwd(); remove broken hub-sideisGitAvailableretry; improve cwd vs binary launch error messages.scripts/build.ts, install script, npm pack, Docker stages,and distribution manifest (
platformArchiveLayoutkeeps archive members insync); smoke tests assert
--versionand stdio handshake.mango doctorruntime probe handles unrunnable sibling paths withoutaborting; codec uses a cheap pending-buffer bound for large split frames.
Test Plan
bun run checkpasses (CI: Check / tooling + typecheck)bun run testpasses (CI: Unit & Integration Tests)bun run buildpasses (CI: Build frontend + API)@mangostudio/shared/i18n(no hardcoded strings)Screenshots / GIFs
N/A (no visual redesign beyond the add-environment dialog).
Notes
globwithoutcwdand no chat workdir now errorsinstead of searching the hub launch directory.
+5s+40s across the 8-target matrix); compile wall timegzip per archive (
unchanged when hub and runtime build concurrently.
pathStyletargets still refuse connect by design.and
BETTER_AUTH_SECRET(covered in spawn integration tests).