From ff9eac1456fac6ad02ddddb88cb533066c39bb57 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 08:11:16 +0200
Subject: [PATCH 01/24] Feat: add `jaiph serve` HTTP API with OpenAPI and
Swagger UI
Add a new `jaiph serve` command that exposes workflows over HTTP instead
of only stdio JSON-RPC, so jaiph can run as a deployed, network-callable
service. Built hand-rolled on node:http with no new runtime dependencies.
The server mirrors `jaiph mcp` startup (graph load, diagnostics, --env,
Docker image prep, credential preflight) and serves run-resource endpoints
under /v1, a browser-usable /docs Swagger UI (CDN assets pinned with SRI),
and a per-request OpenAPI 3.1 document generated by a pure buildOpenApi
from the same deriveTools exposure rules as MCP. Workflow execution reuses
the MCP call layer, now moved to src/cli/exec/call.ts (McpCallResult
renamed to WorkflowCallResult, caller-supplied runId), and the generation
hot-reload machinery is extracted to src/cli/shared/generation.ts shared
by both commands. Adds bearer auth (JAIPH_SERVE_TOKEN, required for /v1
and for non-loopback binds), a concurrency cap (JAIPH_SERVE_MAX_CONCURRENT),
body/content-type limits, graceful drain-then-cancel shutdown, docs, and
unit/integration/e2e coverage.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
QUEUE.md | 35 ----
README.md | 5 +-
docs/_layouts/docs.html | 1 +
docs/cli.md | 41 ++++
docs/env-vars.md | 2 +
docs/index.html | 5 +
docs/serve.md | 89 ++++++++
e2e/test_all.sh | 1 +
e2e/tests/07_installer_binary.sh | 17 +-
e2e/tests/147_serve_http_api.sh | 125 +++++++++++
integration/serve-server.test.ts | 254 +++++++++++++++++++++++
package-lock.json | 139 +++++++++++++
package.json | 1 +
src/cli/commands/mcp.ts | 147 ++++---------
src/cli/commands/serve.ts | 284 +++++++++++++++++++++++++
src/cli/{mcp => exec}/call.ts | 76 ++++---
src/cli/index.ts | 4 +
src/cli/serve/docs.test.ts | 38 ++++
src/cli/serve/docs.ts | 51 +++++
src/cli/serve/handler.test.ts | 292 ++++++++++++++++++++++++++
src/cli/serve/handler.ts | 342 +++++++++++++++++++++++++++++++
src/cli/serve/openapi.test.ts | 79 +++++++
src/cli/serve/openapi.ts | 231 +++++++++++++++++++++
src/cli/serve/server.ts | 77 +++++++
src/cli/shared/generation.ts | 136 ++++++++++++
src/cli/shared/usage.ts | 39 +++-
27 files changed, 2340 insertions(+), 175 deletions(-)
create mode 100644 docs/serve.md
create mode 100755 e2e/tests/147_serve_http_api.sh
create mode 100644 integration/serve-server.test.ts
create mode 100644 src/cli/commands/serve.ts
rename src/cli/{mcp => exec}/call.ts (80%)
create mode 100644 src/cli/serve/docs.test.ts
create mode 100644 src/cli/serve/docs.ts
create mode 100644 src/cli/serve/handler.test.ts
create mode 100644 src/cli/serve/handler.ts
create mode 100644 src/cli/serve/openapi.test.ts
create mode 100644 src/cli/serve/openapi.ts
create mode 100644 src/cli/serve/server.ts
create mode 100644 src/cli/shared/generation.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13377deb..d9fd16e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,12 @@
## Summary
+- **Serve workflows over HTTP:** run `jaiph serve ./tools.jh` to expose a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so a CI job, a Kubernetes deployment, or any HTTP client can invoke tested workflows and inspect their runs — no MCP client or local jaiph install required. Bearer-token auth, a concurrency cap, and the same durable `.jaiph/runs/` records as `jaiph run` are built in.
+
## All changes
+- **Feat — `jaiph serve`: HTTP API with OpenAPI 3.1 + Swagger UI:** A new command turns a `.jh` file into a network-reachable, self-describing HTTP service, complementing `jaiph mcp` (which exposes the same workflows only over stdio JSON-RPC to a co-located parent). `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... ` (default `127.0.0.1:5247`) is dispatched from `src/cli/index.ts` and implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`, hand-rolled on `node:http` with **no runtime npm dependencies** (project policy — `package.json` `dependencies` stays absent; the MCP server set the precedent). Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (errors to stderr, exit `1`), `--env` resolved once via `resolveEnvPairs`, Docker config + image prepared once, credential pre-flight as warnings, and a sandbox-mode notice; all logs go to stderr and one startup line prints the listen URL and the `/docs` URL. Endpoints: `GET /` → `302 /docs`; unauthenticated `GET /healthz`, `GET /openapi.json`, `GET /docs`; and bearer-gated `GET /v1/workflows`, `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → terminal `200`), `GET /v1/runs`, `GET /v1/runs/{id}`, `POST /v1/runs/{id}/cancel`. A run is a durable resource `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` with `status ∈ running|succeeded|failed|cancelled`; **a workflow failure is not an HTTP error** — it comes back `200`/`202` with `status:"failed"` and the same failure narrative `jaiph mcp` returns. Errors use `{error:{code,message}}`: `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415 E_UNSUPPORTED_MEDIA_TYPE` (non-`application/json` body), `429 E_TOO_MANY_RUNS`. Workflow exposure, naming, descriptions, and request-body schemas come from the shared `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties:false`), with param validation mirroring MCP's `-32602` rules as HTTP `400`. **Auth:** bearer token from `JAIPH_SERVE_TOKEN` (env only, never argv), constant-time compared, required on all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only, a documented trade-off) — and binding a non-loopback `--host` without the token set is a startup error before any socket opens. **Limits:** `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs → `429`. `GET /openapi.json` returns OpenAPI **3.1.0** generated per request by the pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`) — one concrete path per workflow (own `operationId`, `#`-comment description, MCP input schema as request body), the run-resource paths, run/error component schemas, and a bearer `securityScheme`. `GET /docs` serves a static Swagger UI shell (`src/cli/serve/docs.ts`) loading `swagger-ui-dist` from a CDN with a **pinned exact version + SRI `integrity` hashes + `crossorigin`** on both assets and `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})` — no vendored assets, so `/docs` needs browser internet access and `/openapi.json` is the offline fallback (air-gap consequence recorded in the design doc). Execution reuses the MCP call layer, **moved** from `src/cli/mcp/call.ts` to `src/cli/exec/call.ts` (`McpCallResult` → `WorkflowCallResult`, caller now supplies `runId`, result extended with `{runDir?, exitStatus?, signal?}`); sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls. The generation/hot-reload machinery (`loadState`, watch/rewatch, generation dirs) was extracted from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts` and is now shared by both commands; editing a served source re-derives the workflow set and the OpenAPI document with no restart, and a superseded generation's scripts dir is deleted only after its in-flight runs finish (refcounted, since HTTP runs can outlive a reload). Shutdown: the first `SIGINT`/`SIGTERM` stops accepting and drains in-flight runs, a second signal cancels them, exit `0`. `jaiph mcp` behavior is unchanged after the extraction (its own tests prove it). Tests: `src/cli/serve/handler.test.ts` (injected-deps handler — unknown workflow `404`; missing / non-string / unexpected param `400`; cancel `202` → terminal `cancelled` with child + container teardown, cancel-on-terminal `409`; `429`/`413`/`415`; auth matrix incl. non-loopback-without-token exit `1`), `src/cli/serve/openapi.test.ts` (output passes a real OpenAPI 3.1 validator devDependency; one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture), `src/cli/serve/docs.test.ts` (pinned version + `integrity` + `crossorigin` on both assets), `integration/serve-server.test.ts` (real server on port 0 — `wait=true` round-trips a `return` value into `result_text` with `status:"succeeded"`; async `202` + `Location` polled to the same terminal result; failing workflow → HTTP `200` with `status:"failed"`, `exit_status`, and `run dir:` in `result_text`; hot-reload surfaces a new workflow in `/openapi.json` and `/v1/workflows` while a pre-reload run still completes), and `e2e/tests/147_serve_http_api.sh` (wired into `e2e/test_all.sh`). Docs: new [Serve workflows over HTTP](serve.md) how-to, a `jaiph serve` section in [CLI](cli.md), `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` rows in [Environment variables](env-vars.md), `printUsage` in `src/cli/shared/usage.ts`, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23`; contract in `design/2026-07-23-serve-http-api.md`.)
+
# 0.11.0
## Summary
diff --git a/QUEUE.md b/QUEUE.md
index ddf71b2c..5d29085f 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,41 +14,6 @@ Process rules:
***
-## Feat: `jaiph serve` — HTTP API with OpenAPI + Swagger UI #dev-ready
-
-**Source:** Deployability feedback (2026-07-23): workflows must be invokable over the network and self-describing, not bound to a local stdio parent. Full contract: `design/2026-07-23-serve-http-api.md` — that document is the spec; this task pins the deliverable and its acceptance.
-
-**Problem:** `jaiph mcp` (`src/cli/commands/mcp.ts`) exposes workflows only over stdio JSON-RPC to a co-located parent process. There is no way to invoke a workflow over HTTP, no machine-readable API description, and no browser-usable surface. This blocks running jaiph as a deployed service (docker/kubernetes) callable by other systems.
-
-**Required behavior** (details, endpoint table, and error contract in the design doc):
-
-* New command `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... `, default `127.0.0.1:5247`, dispatched from `src/cli/index.ts`, implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`. Hand-rolled on `node:http` — **no runtime npm dependencies** (project policy; the MCP server set the precedent). devDependencies for tests are fine.
-* Startup identical in spirit to `jaiph mcp`: graph load + `collectDiagnostics` (errors → stderr, exit 1), `--env` resolved once via `resolveEnvPairs`, Docker config resolved once, image prepared once, credential preflight as warnings, sandbox-mode startup notice. Logs to stderr; startup line prints listen URL + `/docs` URL.
-* Endpoints (this task): `GET /` → 302 `/docs`; `GET /healthz`; `GET /openapi.json`; `GET /docs`; `GET /v1/workflows`; `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → `200` terminal); `GET /v1/runs`; `GET /v1/runs/{id}`; `POST /v1/runs/{id}/cancel`. Run object and `{error:{code,message}}` shape per the design doc. A workflow failure is **not** an HTTP error (run object `status:"failed"`, HTTP 200/202).
-* Exposure, naming, descriptions, and request-body schemas come from `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties: false`). Param validation mirrors the MCP `-32602` rules as HTTP 400.
-* Execution reuses the MCP call layer: move `src/cli/mcp/call.ts` to `src/cli/exec/call.ts`, rename `McpCallResult` → `WorkflowCallResult`, let the caller supply `runId` (today created inside `callWorkflow`), and extend the result with `{runDir?, exitStatus?, signal?}`. `jaiph mcp` behavior is unchanged after the move (its tests prove it). Sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls.
-* Hot reload: extract the generation machinery (`loadState`, watch/rewatch, generation dirs) from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts`, used by both commands. For serve, a superseded generation's out dir is deleted only after its in-flight runs finish (refcount), since HTTP runs can outlive a reload.
-* Auth: bearer token from `JAIPH_SERVE_TOKEN`, constant-time compare; required for all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only — documented trade). **Binding a non-loopback host without the token set is a startup error.**
-* Concurrency cap `JAIPH_SERVE_MAX_CONCURRENT` (default 4) on simultaneous runs → `429`. Body cap 1 MiB → `413`; non-JSON POST → `415`.
-* `GET /openapi.json`: OpenAPI **3.1.0** generated per request by a pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`): one concrete path per workflow (own `operationId`, description from `#` comments, MCP input schema as request body), the run-resource paths, run/error component schemas, bearer `securityScheme`.
-* `GET /docs`: static Swagger UI shell loading `swagger-ui-dist` from CDN with **pinned exact version + SRI integrity hashes + crossorigin**, `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})`. No embedded/vendored UI assets (decision + air-gap consequence recorded in the design doc; `/openapi.json` is the offline fallback).
-* Shutdown: first SIGINT/SIGTERM stops accepting and drains in-flight runs; second signal cancels them; exit 0.
-* Docs: new `docs/serve.md` how-to; `docs/cli.md` section; `printUsage` in `src/cli/shared/usage.ts`; README bullet; `docs/env-vars.md` rows for `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` (src-parity docs-lint pins every new `JAIPH_*` name).
-
-Acceptance:
-
-* Integration test (real server, port 0, fixture `.jh`): `POST /v1/workflows/{name}/runs?wait=true` round-trips a workflow `return` value in `result_text` with `status:"succeeded"`; async POST returns `202` + `Location`, and polling `GET /v1/runs/{id}` reaches the same terminal result; the run dir exists under `.jaiph/runs/` with `run_summary.jsonl`.
-* Integration test: a failing workflow returns HTTP 200 (`wait=true`) with `status:"failed"`, `exit_status` set, and `result_text` containing the failed step and `run dir:` — proving workflow failure is not an HTTP error.
-* Unit tests (injected-deps handler, `McpServer`-style): unknown workflow → 404; missing / non-string / unexpected param key → 400; cancel → 202 then terminal `cancelled` (child + container teardown invoked), cancel on terminal run → 409; concurrency cap → 429; body cap → 413; non-JSON POST → 415.
-* Auth matrix test: with `JAIPH_SERVE_TOKEN` set, `/v1/*` without or with a wrong bearer → 401 and correct bearer → 200, while `/healthz`, `/openapi.json`, `/docs` answer 200 unauthenticated; a unit test proves non-loopback `--host` without the token exits 1 before listening.
-* `buildOpenApi` output passes a real OpenAPI 3.1 schema validator (devDependency, test-only); a test asserts one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture.
-* A test asserts the `/docs` HTML pins an exact `swagger-ui-dist` version with `integrity` + `crossorigin` attributes on both assets.
-* Hot-reload integration test: adding a workflow to the fixture file surfaces it in `/openapi.json` and `/v1/workflows` without restart; a run started before the reload still completes successfully.
-* `jaiph mcp` unit/integration tests pass unchanged after the `call.ts`/generation extraction (no behavior drift in the shared layer).
-* `package.json` `dependencies` remains absent/empty; `npm test` passes (which enforces the env-vars docs parity rows).
-
-***
-
## Feat: `jaiph serve` run inspection — live event stream + artifacts #dev-ready
**Source:** Deployability feedback (2026-07-23): "a UI on top to be able to inspect what it is doing" — the API half of that is streaming run events and exposing artifacts over HTTP. Contract: `design/2026-07-23-serve-http-api.md` (§ Events streaming). Requires the `jaiph serve` command (`src/cli/commands/serve.ts`, run registry + bearer auth) already in the codebase.
diff --git a/README.md b/README.md
index ded0dc63..013a88ae 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md)
-> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
+> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
---
@@ -27,10 +27,11 @@
- **Safety and inspectability** — Docker-backed sandbox for **`jaiph run`** (env-controlled; see [Sandboxing](docs/sandboxing.md) and [Run in a Docker sandbox](docs/sandbox-run.md)); live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)).
- **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/` ([Use & publish a library](docs/libraries.md)), and optional `hooks.json` ([CLI](docs/cli.md), [Add a hook](docs/hooks.md)).
- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)).
+- **HTTP API** — `jaiph serve ./tools.jh` serves the same workflows over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs ([Serve workflows over HTTP](docs/serve.md)).
## Core components
-- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only.
+- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp` / `serve`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only.
- **Parser** (`src/parser.ts`, `src/parse/*`) — `.jh` / `.test.jh` → AST.
- **Validator** (`src/transpile/validate.ts`) — imports and symbol references at compile time.
- **Transpiler** (`src/transpile/*`) — emits atomic `script` files under `scripts/` only (no workflow-level shell).
diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html
index d7053a1c..3db3c43b 100644
--- a/docs/_layouts/docs.html
+++ b/docs/_layouts/docs.html
@@ -55,6 +55,7 @@
diff --git a/docs/cli.md b/docs/cli.md
index 7c960d6c..f6ce3412 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -346,6 +346,47 @@ Tool descriptions come from the `#` comment lines directly above each workflow (
- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Set `JAIPH_INPLACE=1` to bind the real workspace read-write so tool effects land live (opt-in), or `JAIPH_UNSAFE=true` to run on the host with no sandbox.
- Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr.
+## `jaiph serve`
+{: #jaiph-serve}
+
+Serve a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Same exposure rules and execution layer as `jaiph mcp`, over HTTP instead of stdio. See [Serve workflows over HTTP](/how-to/serve) for the recipe.
+
+```text
+jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]...
+```
+
+| Flag | Argument | Effect |
+|---|---|---|
+| `--host` | `` | Listen address (default `127.0.0.1`). Binding a non-loopback host without `JAIPH_SERVE_TOKEN` set aborts startup. |
+| `--port` | `` | Listen port (default `5247`). `0` picks a free port. |
+| `--workspace` | `` | Workspace root for import resolution (default: auto-detected). |
+| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env`, resolved once at startup and applied to every run for the server's lifetime. |
+| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
+
+Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), one-time Docker image preparation, credential pre-flight as warnings, and a sandbox-mode notice. All logs go to stderr; one startup line prints the listen URL and the `/docs` URL.
+
+### Endpoints
+
+| Method & path | Auth | Behaviour |
+|---|---|---|
+| `GET /` | none | `302` → `/docs`. |
+| `GET /healthz` | none | `200 {status, version, tools, in_flight}`. |
+| `GET /openapi.json` | none | OpenAPI 3.1 document, regenerated per request (hot reload needs no cache invalidation). |
+| `GET /docs` | none | Swagger UI shell (loads `swagger-ui-dist` from a pinned, SRI-verified CDN). |
+| `GET /v1/workflows` | bearer | `{workflows: [{name, description, params}]}`. |
+| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. |
+| `GET /v1/runs` | bearer | Runs started by this process (in-memory), newest first. |
+| `GET /v1/runs/{id}` | bearer | The run object. `404` unknown. |
+| `POST /v1/runs/{id}/cancel` | bearer | `202`; the run reaches `cancelled`. `409` if already terminal. |
+
+The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled`. **A workflow failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415` (non-`application/json` body), and `429 E_TOO_MANY_RUNS`.
+
+### Auth and limits
+
+- `JAIPH_SERVE_TOKEN` (env only) enables a bearer token required on every `/v1/*` request, compared in constant time. `/healthz`, `/openapi.json`, and `/docs` stay unauthenticated (schema metadata only). On loopback the token is optional; binding a non-loopback `--host` without it is a startup error.
+- `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs; requests beyond it get `429`.
+- Execution and hot reload are identical to `jaiph mcp`; a superseded generation's scripts dir survives until its in-flight HTTP runs finish.
+
## Environment variables
See [Environment variables](env-vars.md) for the complete inventory. The variables most relevant to CLI behaviour:
diff --git a/docs/env-vars.md b/docs/env-vars.md
index dfe1c050..5aebaae7 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -78,6 +78,8 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_RUNS_DIR` | host, runtime | path | `.jaiph/runs` under the workspace | `run.logs_dir` | Root directory for run logs. Inside Docker the host CLI overrides this to `/jaiph/run`. |
| `JAIPH_RUNS_DIR_LOCKED` | internal | bool | — | — | Lock flag for `JAIPH_RUNS_DIR`. |
| `JAIPH_SCRIPTS` | internal | path | — | — | Directory of emitted `script` files for this run. Set after `buildScripts()`. Any parent-shell value is cleared before launch. |
+| `JAIPH_SERVE_MAX_CONCURRENT` | host | int | `4` | — | `jaiph serve` — cap on simultaneously-running workflows; requests beyond it get HTTP `429`. Must be a positive integer. |
+| `JAIPH_SERVE_TOKEN` | host | string | — | — | `jaiph serve` — bearer token required on every `/v1/*` request (constant-time compared). Unset leaves `/v1/*` open on loopback; binding a non-loopback `--host` without it is a startup error. `/healthz`, `/openapi.json`, `/docs` are always unauthenticated. |
| `JAIPH_SKILL_PATH` | host | path | — | — | When set and the path exists, `jaiph init` writes `.jaiph/SKILL.md` from that file. Otherwise the CLI walks an install-relative search. |
| `JAIPH_SOURCE_ABS` | internal | path | — | — | Absolute path to the entry `.jh` file. Set by the CLI before spawning the runner. |
| `JAIPH_SOURCE_FILE` | internal | string (basename) | entry-file basename | — | Used to name run directories. |
diff --git a/docs/index.html b/docs/index.html
index 03cfa29c..b0aaa6a2 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -564,6 +564,11 @@
Runtime
MCP tools
over stdio, so any MCP client (Claude Code, Claude Desktop, Cursor) can call tested Jaiph workflows
as tools. See Serve workflows as MCP tools.
+
HTTP API.jaiph serve ./tools.jh serves the same workflows over HTTP
+ with a generated OpenAPI 3.1 document and a browser Swagger UI at /docs,
+ so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs. See
+ Serve workflows over HTTP.
Samples
Jaiph source code is built mostly with real Jaiph workflows. The
diff --git a/docs/serve.md b/docs/serve.md
new file mode 100644
index 00000000..eda48df7
--- /dev/null
+++ b/docs/serve.md
@@ -0,0 +1,89 @@
+---
+title: Serve workflows over HTTP
+permalink: /how-to/serve
+diataxis: how-to
+---
+
+# Serve workflows over HTTP
+
+This recipe turns a `.jh` file into an HTTP API: `jaiph serve ./tools.jh` exposes the file's workflows as endpoints, publishes a machine-readable [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document, and serves a browser-usable Swagger UI. Anything that speaks HTTP — a CI job, a Kubernetes deployment, another service, a human with a browser — can invoke tested workflows and inspect their runs without an MCP client or a local jaiph install.
+
+It reuses the same compile-time validation, sandboxed execution, and `.jaiph/runs/` artifacts as [`jaiph run`](cli.md#jaiph-run), and the same exposure rules as [`jaiph mcp`](mcp.md). Where MCP binds the server to a co-located stdio parent, `jaiph serve` makes the workflows reachable over the network.
+
+> **Security:** an HTTP-exposed workflow is arbitrary shell reachable by anyone who can reach the port (and, if set, holds the token) — that is the point. Bind to loopback for local use; for anything else set `JAIPH_SERVE_TOKEN`, put the server behind a TLS-terminating reverse proxy / ingress (the process speaks plain HTTP), and treat the run directory as sensitive.
+
+## Prerequisites
+
+- A `.jh` file with at least one workflow.
+- Agent credentials for any exposed workflow that uses `prompt` — see [Authenticate agent backends](/how-to/agent-auth). Set them on the **host**; in Docker mode the backend's credential keys are forwarded through the env allowlist. Forward any other host variable a workflow needs with `--env` (same rules as `jaiph run`).
+
+## 1. Start the server
+
+```bash
+jaiph serve ./tools.jh
+# jaiph serve: listening on http://127.0.0.1:5247 — API docs at http://127.0.0.1:5247/docs (2 workflow(s))
+```
+
+Defaults are `--host 127.0.0.1` and `--port 5247`. All logs go to stderr. Startup validates the file exactly like [`jaiph mcp`](cli.md#jaiph-mcp): a compile diagnostic prints `file:line:col CODE message` to stderr and exits `1`.
+
+## 2. Discover the workflows
+
+```bash
+curl -s http://127.0.0.1:5247/v1/workflows | jq
+```
+
+`GET /openapi.json` returns the full OpenAPI 3.1 document (one path per workflow, each with the workflow's `#`-comment description and its parameters as a JSON request-body schema). `GET /healthz` is an unauthenticated liveness/readiness probe.
+
+## 3. Invoke a workflow
+
+A run is a durable resource. `POST /v1/workflows/{name}/runs` starts one; the body is a JSON object of the workflow's parameters (all strings).
+
+```bash
+# Async: 202 + a Location header pointing at the run resource.
+curl -si -X POST http://127.0.0.1:5247/v1/workflows/greet/runs \
+ -H 'content-type: application/json' -d '{"name":"world"}'
+
+# Synchronous: block until the run is terminal, then return the final object.
+curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
+ -H 'content-type: application/json' -d '{"name":"world"}' | jq
+```
+
+The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first), and stop one with `POST /v1/runs/{id}/cancel`.
+
+## 4. Use the Swagger UI
+
+Open `http://127.0.0.1:5247/docs` in a browser to get a live form for every workflow. When a token is configured, paste it into the **Authorize** box (Swagger UI keeps it across reloads). The UI loads its assets from a pinned, SRI-verified CDN, so `/docs` needs internet access in the browser; air-gapped operators use `/openapi.json` with any locally-hosted renderer.
+
+## 5. Require a token and expose the port
+
+The token comes from the environment (never argv, which leaks into process listings). With it set, every `/v1/*` request must carry `Authorization: Bearer `; `/healthz`, `/openapi.json`, and `/docs` stay open.
+
+```bash
+JAIPH_SERVE_TOKEN=secret jaiph serve --host 0.0.0.0 --port 8080 ./tools.jh
+curl -s http://host:8080/v1/workflows -H 'authorization: Bearer secret' | jq
+```
+
+Binding a non-loopback `--host` **without** `JAIPH_SERVE_TOKEN` is a startup error — the server refuses to expose unauthenticated arbitrary shell. Cap simultaneous runs with `JAIPH_SERVE_MAX_CONCURRENT` (default `4`); requests beyond the cap get `429`. See [Environment variables](env-vars.md) for both.
+
+Execution honors the same env-driven sandbox as [`jaiph run`](cli.md#jaiph-run) and [Run in a Docker sandbox](/how-to/sandbox-run): a Docker sandbox with an isolated workspace by default. `JAIPH_INPLACE=1` keeps the sandbox but binds the real workspace read-write so run effects land live; `JAIPH_UNSAFE=true` runs on the host with no sandbox at all. Publish files a run produces with [artifacts](/how-to/artifacts). Editing a served source hot-reloads the workflow set (and the OpenAPI document) with no restart; runs already in flight keep running.
+
+## Verification
+
+```bash
+# Health probe answers, unauthenticated.
+curl -s http://127.0.0.1:5247/healthz | jq -e '.status == "ok"'
+
+# A synchronous run round-trips its return value with a durable run dir.
+curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
+ -H 'content-type: application/json' -d '{"name":"ok"}' \
+ | jq -e '.status == "succeeded" and (.run_dir | length > 0)'
+```
+
+Both `jq -e` checks exit `0` when the contract holds. The run's durable record is under `.jaiph/runs/…/run_summary.jsonl` (`run_dir` in the response), the same artifact layout as `jaiph run`.
+
+## Related
+
+- [CLI — `jaiph serve`](cli.md#jaiph-serve) — flag and endpoint reference.
+- [Serve workflows as MCP tools](mcp.md) — the stdio sibling with the same exposure rules.
+- [Run in a Docker sandbox](/how-to/sandbox-run) — the execution sandbox HTTP runs use.
+- [Environment variables](env-vars.md) — `JAIPH_SERVE_TOKEN`, `JAIPH_SERVE_MAX_CONCURRENT`, and the sandbox controls.
diff --git a/e2e/test_all.sh b/e2e/test_all.sh
index 93c8f62a..88fe6cf1 100755
--- a/e2e/test_all.sh
+++ b/e2e/test_all.sh
@@ -103,6 +103,7 @@ TEST_SCRIPTS=(
"e2e/tests/139_mcp_server_session.sh"
"e2e/tests/140_env_passthrough.sh"
"e2e/tests/141_mcp_docker_sandbox.sh"
+ "e2e/tests/147_serve_http_api.sh"
"e2e/tests/146_trusted_envs.sh"
"e2e/tests/210_standalone_binary.sh"
)
diff --git a/e2e/tests/07_installer_binary.sh b/e2e/tests/07_installer_binary.sh
index a2c1da75..7216e73d 100755
--- a/e2e/tests/07_installer_binary.sh
+++ b/e2e/tests/07_installer_binary.sh
@@ -93,9 +93,19 @@ printf 'real-binary-bytes' > "${RELEASE_DIR}/${HOST_BIN_NAME}"
# reaches the verify step and fails with a checksum mismatch (not an http 404).
printf '%s %s\n' "0000000000000000000000000000000000000000000000000000000000000000" "${HOST_BIN_NAME}" \
> "${RELEASE_DIR}/SHA256SUMS"
-# Provide a placeholder sig file so the installer proceeds past the sig-download
-# step and reaches the checksum verification (the real test target here).
-printf 'placeholder-sig\n' > "${RELEASE_DIR}/SHA256SUMS.minisig"
+# The installer verifies the detached signature BEFORE the checksum, so a bogus
+# signature would fail first and never reach the checksum step. To exercise the
+# checksum path, sign the (tampered) SHA256SUMS with a throwaway key and hand the
+# installer its matching public key. When minisign is absent the installer skips
+# signature verification, so a placeholder sig is enough to reach the checksum.
+TEST_PUBKEY=""
+if command -v minisign >/dev/null 2>&1; then
+ minisign -G -W -p "${RELEASE_DIR}/test.pub" -s "${RELEASE_DIR}/test.key" >/dev/null 2>&1
+ minisign -S -s "${RELEASE_DIR}/test.key" -m "${RELEASE_DIR}/SHA256SUMS" >/dev/null 2>&1
+ TEST_PUBKEY="$(tail -n 1 "${RELEASE_DIR}/test.pub")"
+else
+ printf 'placeholder-sig\n' > "${RELEASE_DIR}/SHA256SUMS.minisig"
+fi
bad_status=0
# Unset JAIPH_REPO_URL: the shared e2e context points it at this repo root,
@@ -104,6 +114,7 @@ bad_output="$(
unset JAIPH_REPO_URL
JAIPH_RELEASE_BASE_URL="file://${RELEASE_DIR}" \
JAIPH_BIN_DIR="${BIN_DIR_BAD}" \
+ JAIPH_MINISIGN_PUBLIC_KEY="${TEST_PUBKEY}" \
bash "${INSTALL_SCRIPT}" 2>&1
)" || bad_status=$?
e2e::assert_equals "${bad_status}" "1" "checksum mismatch exits non-zero"
diff --git a/e2e/tests/147_serve_http_api.sh b/e2e/tests/147_serve_http_api.sh
new file mode 100755
index 00000000..51541bca
--- /dev/null
+++ b/e2e/tests/147_serve_http_api.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+#
+# serve 1/1 — `jaiph serve` HTTP API end-to-end (host mode)
+# ========================================================
+# Black-box coverage through the real `jaiph` entrypoint (design:
+# design/2026-07-23-serve-http-api.md -> "Testing"):
+#
+# Start `jaiph serve --port 0 ` as a background child, discover
+# the bound port from its stderr startup line, then drive the HTTP surface
+# with curl:
+# - GET /healthz → {status:"ok", ...} unauthenticated
+# - GET /openapi.json → OpenAPI 3.1.0 with a per-workflow path
+# - POST .../greet/runs?wait=true → succeeded run round-trips the return value
+# - POST .../boom/runs?wait=true → HTTP 200 with status:"failed" (a workflow
+# failure is NOT an HTTP error)
+# Assertions compare the meaningful JSON fields for equality (the run object
+# carries a volatile run_dir + timestamps, so full-body equality is not
+# feasible — hence field-level checks, per the e2e assertion policy).
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT_DIR}/e2e/lib/common.sh"
+trap e2e::cleanup EXIT
+
+e2e::prepare_test_env "serve_http_api"
+TEST_DIR="${JAIPH_E2E_TEST_DIR}"
+
+if ! command -v python3 >/dev/null 2>&1; then
+ e2e::fail "python3 required for JSON response validation"
+fi
+if ! command -v curl >/dev/null 2>&1; then
+ e2e::fail "curl required for HTTP e2e"
+fi
+
+e2e::file "tools.jh" <<'EOF'
+# Greets the given name.
+workflow greet(name) {
+ return "hello ${name}"
+}
+
+# Always fails so the run reports a failure.
+workflow boom() {
+ fail "boom-failed"
+}
+EOF
+
+e2e::section "jaiph serve exposes an HTTP API over host-mode runs"
+
+serve_err="${TEST_DIR}/serve_stderr.txt"
+: >"${serve_err}"
+
+# `--port 0` binds a free port; the startup line on stderr carries it.
+jaiph serve --port 0 "${TEST_DIR}/tools.jh" >/dev/null 2>"${serve_err}" &
+# Reuse the harness's server-pid slot so e2e::cleanup tears the server down.
+E2E_SERVER_PID="$!"
+
+port=""
+for _ in $(seq 1 50); do
+ port="$(sed -nE 's#.*listening on http://[^:]+:([0-9]+).*#\1#p' "${serve_err}" | head -1)"
+ if [[ -n "${port}" ]]; then
+ break
+ fi
+ sleep 0.2
+done
+if [[ -z "${port}" ]]; then
+ printf 'serve stderr:\n%s\n' "$(cat "${serve_err}")" >&2
+ e2e::fail "jaiph serve did not print a listen URL"
+fi
+base="http://127.0.0.1:${port}"
+
+# --- /healthz (unauthenticated) ---
+health="$(curl -s "${base}/healthz")"
+health_status="$(printf '%s' "${health}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
+e2e::assert_equals "${health_status}" "ok" "GET /healthz reports status ok"
+
+# --- /openapi.json (unauthenticated, OpenAPI 3.1 with a per-workflow path) ---
+openapi="$(curl -s "${base}/openapi.json")"
+openapi_fields="$(printf '%s' "${openapi}" | python3 -c '
+import json, sys
+d = json.load(sys.stdin)
+print(d["openapi"])
+print("yes" if "/v1/workflows/greet/runs" in d["paths"] else "no")
+')"
+{
+ read -r p_openapi_version
+ read -r p_has_greet_path
+} <<< "${openapi_fields}"
+e2e::assert_equals "${p_openapi_version}" "3.1.0" "GET /openapi.json is OpenAPI 3.1.0"
+e2e::assert_equals "${p_has_greet_path}" "yes" "/openapi.json has a concrete path for the greet workflow"
+
+# --- POST greet ?wait=true → succeeded, return value round-trips ---
+greet="$(curl -s -X POST "${base}/v1/workflows/greet/runs?wait=true" \
+ -H 'content-type: application/json' -d '{"name":"world"}')"
+greet_fields="$(printf '%s' "${greet}" | python3 -c '
+import json, sys
+d = json.load(sys.stdin)
+print(d["status"])
+print(d["result_text"])
+print("yes" if d.get("run_dir") else "no")
+')"
+{
+ read -r p_greet_status
+ read -r p_greet_text
+ read -r p_greet_has_rundir
+} <<< "${greet_fields}"
+e2e::assert_equals "${p_greet_status}" "succeeded" "greet run status is succeeded"
+e2e::assert_equals "${p_greet_text}" "hello world" "greet result_text is the workflow return value"
+e2e::assert_equals "${p_greet_has_rundir}" "yes" "greet run object carries a run_dir"
+
+# --- POST boom ?wait=true → HTTP 200 with status failed (not an HTTP error) ---
+boom_body="${TEST_DIR}/boom_body.json"
+boom_code="$(curl -s -o "${boom_body}" -w '%{http_code}' -X POST \
+ "${base}/v1/workflows/boom/runs?wait=true" -H 'content-type: application/json' -d '{}')"
+e2e::assert_equals "${boom_code}" "200" "a failing workflow is HTTP 200 (workflow failure is not an HTTP error)"
+boom_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "${boom_body}")"
+e2e::assert_equals "${boom_status}" "failed" "boom run status is failed"
+
+# --- GET /v1/workflows lists both exposed workflows ---
+workflows="$(curl -s "${base}/v1/workflows" | python3 -c '
+import json, sys
+names = sorted(w["name"] for w in json.load(sys.stdin)["workflows"])
+print(",".join(names))
+')"
+e2e::assert_equals "${workflows}" "boom,greet" "GET /v1/workflows lists both workflows"
diff --git a/integration/serve-server.test.ts b/integration/serve-server.test.ts
new file mode 100644
index 00000000..afbd609c
--- /dev/null
+++ b/integration/serve-server.test.ts
@@ -0,0 +1,254 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+
+const CLI_PATH = join(process.cwd(), "dist/src/cli.js");
+
+const BASE_FIXTURE = [
+ "script sleeper = `sleep 1`",
+ "# Greets the given name.",
+ "workflow greet(name) {",
+ ' return "hello ${name}"',
+ "}",
+ "",
+ "# Fails on purpose for tests.",
+ "workflow boom() {",
+ ' fail "boom went off"',
+ "}",
+ "",
+ "# Sleeps briefly, then returns.",
+ "workflow slow() {",
+ " run sleeper()",
+ ' return "woke"',
+ "}",
+ "",
+].join("\n");
+
+function serveEnv(runsRoot: string, extra?: Record): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
+ PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
+ ...extra,
+ };
+}
+
+interface ServeProc {
+ baseUrl: string;
+ stderr: () => string;
+ close: () => Promise;
+}
+
+/** Spawn `jaiph serve --port 0`, resolving once it logs its bound listen URL. */
+function startServe(fixture: string, cwd: string, env: NodeJS.ProcessEnv, extraArgv: string[] = []): Promise {
+ const child = spawn("node", [CLI_PATH, "serve", "--port", "0", ...extraArgv, fixture], {
+ cwd,
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let stderrBuf = "";
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`serve did not start\nstderr:\n${stderrBuf}`)), 20_000);
+ child.stderr!.setEncoding("utf8");
+ child.stderr!.on("data", (chunk: string) => {
+ stderrBuf += chunk;
+ const m = stderrBuf.match(/listening on (http:\/\/[^ ]+)/);
+ if (m) {
+ clearTimeout(timer);
+ resolve({
+ baseUrl: m[1],
+ stderr: () => stderrBuf,
+ close: () =>
+ new Promise((res) => {
+ child.on("exit", () => res());
+ child.kill("SIGTERM");
+ setTimeout(() => child.kill("SIGKILL"), 8_000).unref();
+ }),
+ });
+ }
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timer);
+ reject(new Error(`serve exited early (code ${code})\nstderr:\n${stderrBuf}`));
+ });
+ });
+}
+
+function delay(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+async function pollRun(baseUrl: string, id: string, timeoutMs = 20_000): Promise {
+ const start = Date.now();
+ for (;;) {
+ const res = await fetch(`${baseUrl}/v1/runs/${id}`);
+ assert.equal(res.status, 200);
+ const run = await res.json();
+ if (run.status !== "running") return run;
+ if (Date.now() - start > timeoutMs) throw new Error(`run ${id} did not finish; last=${JSON.stringify(run)}`);
+ await delay(150);
+ }
+}
+
+test("jaiph serve: wait=true round-trips a workflow return value as succeeded", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-wait-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ const res = await fetch(`${srv.baseUrl}/v1/workflows/greet/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "world" }),
+ });
+ assert.equal(res.status, 200);
+ const run = await res.json();
+ assert.equal(run.status, "succeeded");
+ assert.equal(run.result_text, "hello world");
+ assert.ok(run.run_dir && existsSync(join(run.run_dir, "run_summary.jsonl")), "run dir has run_summary.jsonl");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: async POST returns 202 + Location and polling reaches the same terminal result", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-async-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ const res = await fetch(`${srv.baseUrl}/v1/workflows/greet/runs`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "async" }),
+ });
+ assert.equal(res.status, 202);
+ const location = res.headers.get("location");
+ const started = await res.json();
+ assert.equal(started.status, "running");
+ assert.equal(location, `/v1/runs/${started.run_id}`);
+
+ const run = await pollRun(srv.baseUrl, started.run_id);
+ assert.equal(run.status, "succeeded");
+ assert.equal(run.result_text, "hello async");
+ assert.ok(existsSync(join(run.run_dir, "run_summary.jsonl")), "durable run_summary.jsonl exists");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: a failing workflow is HTTP 200 with status failed (not an HTTP error)", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-fail-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ const res = await fetch(`${srv.baseUrl}/v1/workflows/boom/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(res.status, 200, "workflow failure must not be an HTTP error");
+ const run = await res.json();
+ assert.equal(run.status, "failed");
+ assert.equal(typeof run.exit_status, "number");
+ assert.notEqual(run.exit_status, 0);
+ assert.match(run.result_text, /failed step/);
+ assert.match(run.result_text, /run dir:/);
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: hot reload surfaces a new workflow and a pre-reload run still completes", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-reload-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ // Start a slow run against the current generation, then reload.
+ const startRes = await fetch(`${srv.baseUrl}/v1/workflows/slow/runs`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(startRes.status, 202);
+ const slowId = (await startRes.json()).run_id;
+
+ // Add a workflow; the watcher reloads without a restart.
+ writeFileSync(jh, `${BASE_FIXTURE}\n# A freshly added tool.\nworkflow extra() {\n return "extra"\n}\n`);
+
+ const start = Date.now();
+ for (;;) {
+ const doc = await (await fetch(`${srv.baseUrl}/openapi.json`)).json();
+ if (doc.paths["/v1/workflows/extra/runs"]) break;
+ if (Date.now() - start > 15_000) throw new Error("reload did not surface the new workflow in /openapi.json");
+ await delay(200);
+ }
+ const wf = await (await fetch(`${srv.baseUrl}/v1/workflows`)).json();
+ assert.ok(wf.workflows.some((w: any) => w.name === "extra"), "/v1/workflows lists the new workflow");
+
+ // The run started before the reload still finishes successfully (its
+ // generation's scripts dir survives until it completes — refcounted).
+ const run = await pollRun(srv.baseUrl, slowId);
+ assert.equal(run.status, "succeeded");
+ assert.equal(run.result_text, "woke");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: with a token, /v1/* needs the bearer while /healthz, /openapi.json, /docs stay open", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-auth-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs"), { JAIPH_SERVE_TOKEN: "s3cret" }));
+ try {
+ assert.equal((await fetch(`${srv.baseUrl}/v1/workflows`)).status, 401);
+ assert.equal((await fetch(`${srv.baseUrl}/v1/workflows`, { headers: { authorization: "Bearer wrong" } })).status, 401);
+ assert.equal((await fetch(`${srv.baseUrl}/v1/workflows`, { headers: { authorization: "Bearer s3cret" } })).status, 200);
+
+ for (const path of ["/healthz", "/openapi.json", "/docs"]) {
+ assert.equal((await fetch(`${srv.baseUrl}${path}`)).status, 200, `${path} is open without a token`);
+ }
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: binding a non-loopback host without JAIPH_SERVE_TOKEN exits 1 before listening", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-noloop-"));
+ try {
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const env = serveEnv(join(root, ".jaiph/runs"));
+ delete env.JAIPH_SERVE_TOKEN;
+ const result = spawnSync("node", [CLI_PATH, "serve", "--host", "0.0.0.0", "--port", "0", jh], {
+ encoding: "utf8",
+ cwd: root,
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ assert.equal(result.status, 1, `expected exit 1, got ${result.status}\n${result.stderr}`);
+ assert.match(result.stderr, /JAIPH_SERVE_TOKEN/);
+ assert.doesNotMatch(result.stderr, /listening on/, "must not bind before failing");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph --help lists jaiph serve", () => {
+ const result = spawnSync("node", [CLI_PATH, "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ assert.equal(result.status, 0);
+ assert.match(result.stdout, /jaiph serve/);
+});
diff --git a/package-lock.json b/package-lock.json
index d4272806..454492b9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+ "@seriousme/openapi-schema-validator": "^2.4.1",
"@types/node": "^24.5.2",
"typescript": "^5.9.2"
}
@@ -32,6 +33,23 @@
"node": ">=18"
}
},
+ "node_modules/@seriousme/openapi-schema-validator": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@seriousme/openapi-schema-validator/-/openapi-schema-validator-2.4.1.tgz",
+ "integrity": "sha512-OX15CKLV2JFGcoXxFVD/CMtWzys+r6G9gArKY8iaUqOkIEqp80ispclk5c8j5i1iEIIlyCRJ0R0N5MddHFg2xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "ajv-formats": "^3.0.1",
+ "js-yaml": "^4.1.0"
+ },
+ "bin": {
+ "bundle-api": "bin/bundle-api-cli.js",
+ "validate-api": "bin/validate-api-cli.js"
+ }
+ },
"node_modules/@types/node": {
"version": "24.10.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
@@ -42,6 +60,87 @@
"undici-types": "~7.16.0"
}
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+ "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -57,6 +156,36 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
@@ -89,6 +218,16 @@
"node": ">=18"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
diff --git a/package.json b/package.json
index 3f8d4d80..ce989d92 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+ "@seriousme/openapi-schema-validator": "^2.4.1",
"@types/node": "^24.5.2",
"typescript": "^5.9.2"
}
diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts
index ef8b42d4..cde893c6 100644
--- a/src/cli/commands/mcp.ts
+++ b/src/cli/commands/mcp.ts
@@ -1,24 +1,20 @@
-import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, unwatchFile, watchFile } from "node:fs";
+import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
+import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { dirname, extname, join, resolve } from "node:path";
import { createInterface } from "node:readline";
-import { loadModuleGraph, writeModuleGraph, type ModuleGraph } from "../../transpile/module-graph";
-import { collectDiagnostics } from "../../transpile/validate";
-import { buildScriptsFromGraph } from "../../transpiler";
-import { resolveModuleMetadata, metadataToConfig } from "../../config";
-import {
- resolveDockerConfig,
- checkDockerAvailable,
- prepareImage,
- selectMcpSandboxMode,
-} from "../../runtime/docker";
import { detectWorkspaceRoot } from "../shared/paths";
import { hasHelpFlag, parseArgs } from "../shared/usage";
-import { resolveRuntimeEnv, resolveEnvPairs } from "../run/env";
-import { preflightAgentCredentials } from "../run/preflight-credentials";
-import { deriveTools, type McpToolSpec } from "../mcp/tools";
+import { resolveEnvPairs } from "../run/env";
import { McpServer } from "../mcp/server";
-import { callWorkflow, type McpCallEnvironment } from "../mcp/call";
+import { callWorkflow } from "../exec/call";
+import {
+ loadGeneration,
+ createSourceWatcher,
+ resolveStartupPosture,
+ WATCH_INTERVAL_MS,
+ type GenerationState,
+} from "../shared/generation";
import { VERSION } from "../../version";
const MCP_USAGE =
@@ -38,60 +34,6 @@ const MCP_USAGE =
"Example:\n" +
" claude mcp add mytools -- jaiph mcp ./tools.jh\n";
-/** How often watchFile polls module sources for hot reload (ms). */
-const WATCH_INTERVAL_MS = 750;
-
-interface McpState {
- graph: ModuleGraph;
- tools: McpToolSpec[];
- callEnv: McpCallEnvironment;
-}
-
-/**
- * Load (or reload) everything one generation of the server needs: module
- * graph, compile-time validation, tool derivation, emitted scripts, and the
- * serialized graph the spawned runners consume. Throws on parse errors;
- * returns diagnostics without throwing on validation errors.
- */
-function loadState(
- inputAbs: string,
- workspaceRoot: string,
- tempRoot: string,
- generation: number,
- extraEnv: Record,
- log: (line: string) => void,
-): { state?: McpState; failures: string[] } {
- const graph = loadModuleGraph(inputAbs, workspaceRoot);
- const diag = collectDiagnostics(graph);
- if (diag.errors.length > 0) {
- return {
- failures: diag.sorted().map((d) => `${d.file}:${d.line}:${d.col} ${d.code} ${d.message}`),
- };
- }
-
- const mod = graph.modules.get(inputAbs)!.ast;
- const { tools, warnings } = deriveTools(mod, inputAbs);
- for (const w of warnings) log(`jaiph mcp: ${w}`);
-
- const outDir = join(tempRoot, `gen-${generation}`);
- mkdirSync(outDir, { recursive: true });
- const { scriptsDir } = buildScriptsFromGraph(graph, outDir);
- const graphFile = join(outDir, ".jaiph-module-graph.json");
- writeModuleGraph(graphFile, graph);
-
- const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
- const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
-
- return {
- state: {
- graph,
- tools,
- callEnv: { inputAbs, workspaceRoot, mod, effectiveConfig, scriptsDir, graphFile, outDir, extraEnv },
- },
- failures: [],
- };
-}
-
export async function runMcp(rest: string[]): Promise {
if (hasHelpFlag(rest)) {
process.stdout.write(MCP_USAGE);
@@ -137,9 +79,9 @@ export async function runMcp(rest: string[]): Promise {
const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-mcp-"));
let generation = 0;
- let state: McpState;
+ let state: GenerationState;
try {
- const loaded = loadState(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log);
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp");
if (!loaded.state) {
for (const f of loaded.failures) log(f);
rmSync(tempRoot, { recursive: true, force: true });
@@ -156,38 +98,25 @@ export async function runMcp(rest: string[]): Promise {
// env-driven Docker selection as `jaiph run`: the workspace is isolated by
// default via a point-in-time snapshot. Inplace is an explicit opt-in via
// JAIPH_INPLACE=1.
- const mod = state.graph.modules.get(inputAbs)!.ast;
- const startupEnv = resolveRuntimeEnv(state.callEnv.effectiveConfig, workspaceRoot, inputAbs);
- const dockerConfig = resolveDockerConfig(resolveModuleMetadata(mod, process.env)?.runtime, startupEnv);
- if (dockerConfig.enabled) {
- // Prepare the image once here rather than per call (a cold pull is slow).
- try {
- checkDockerAvailable();
- prepareImage(dockerConfig);
- } catch (err) {
- log(err instanceof Error ? err.message : String(err));
- rmSync(tempRoot, { recursive: true, force: true });
- return 1;
- }
- const mode = selectMcpSandboxMode(startupEnv);
- if (mode === "inplace") {
- log(
- `jaiph mcp: tool calls run in a Docker sandbox in-place on ${workspaceRoot} ` +
- "(JAIPH_INPLACE=1 opt-in: effects land live on the workspace).",
- );
- } else {
- log(`jaiph mcp: tool calls run in a Docker sandbox (${mode} mode; workspace isolated).`);
+ let dockerConfig: ReturnType["dockerConfig"];
+ try {
+ const posture = resolveStartupPosture(state, inputAbs, workspaceRoot, log);
+ dockerConfig = posture.dockerConfig;
+ if (dockerConfig.enabled) {
+ if (posture.sandboxMode === "inplace") {
+ log(
+ `jaiph mcp: tool calls run in a Docker sandbox in-place on ${workspaceRoot} ` +
+ "(JAIPH_INPLACE=1 opt-in: effects land live on the workspace).",
+ );
+ } else {
+ log(`jaiph mcp: tool calls run in a Docker sandbox (${posture.sandboxMode} mode; workspace isolated).`);
+ }
}
+ } catch (err) {
+ log(err instanceof Error ? err.message : String(err));
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
}
- // Credential pre-flight once at startup (warnings only in MCP mode: the
- // server may outlive a credential fix, and per-call failures still surface).
- const credPreflight = preflightAgentCredentials({
- mod,
- inputAbs,
- runtimeEnv: startupEnv,
- dockerEnabled: dockerConfig.enabled,
- });
- for (const w of [...credPreflight.warnings, ...credPreflight.errors]) log(w);
const server = new McpServer({
serverVersion: VERSION,
@@ -198,6 +127,7 @@ export async function runMcp(rest: string[]): Promise {
dockerConfig,
spec.workflow,
spec.params.map((p) => args[p] ?? ""),
+ randomUUID(),
ctx,
),
write: (message) => {
@@ -208,19 +138,13 @@ export async function runMcp(rest: string[]): Promise {
// Hot reload: poll every module source; on change re-validate and swap the
// generation. Validation failures keep the previous generation serving.
- let watched: string[] = [];
- const rewatch = (): void => {
- for (const f of watched) unwatchFile(f, onSourceChange);
- watched = [...state.graph.modules.keys()];
- for (const f of watched) watchFile(f, { interval: WATCH_INTERVAL_MS }, onSourceChange);
- };
let reloading = false;
const onSourceChange = (): void => {
if (reloading) return;
reloading = true;
try {
generation += 1;
- const loaded = loadState(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log);
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp");
if (!loaded.state) {
log("jaiph mcp: reload failed; keeping the previous tool set:");
for (const f of loaded.failures) log(` ${f}`);
@@ -228,7 +152,7 @@ export async function runMcp(rest: string[]): Promise {
}
const previousOutDir = state.callEnv.outDir;
state = loaded.state;
- rewatch();
+ watcher.rewatch([...state.graph.modules.keys()]);
server.notifyToolsChanged();
log(`jaiph mcp: sources reloaded (${state.tools.length} tool(s))`);
rmSync(previousOutDir, { recursive: true, force: true });
@@ -238,7 +162,8 @@ export async function runMcp(rest: string[]): Promise {
reloading = false;
}
};
- rewatch();
+ const watcher = createSourceWatcher(WATCH_INTERVAL_MS, onSourceChange);
+ watcher.rewatch([...state.graph.modules.keys()]);
log(`jaiph mcp: serving ${state.tools.length} tool(s) from ${inputAbs} over stdio`);
@@ -247,7 +172,7 @@ export async function runMcp(rest: string[]): Promise {
const shutdown = (code: number): void => {
if (settled) return;
settled = true;
- for (const f of watched) unwatchFile(f, onSourceChange);
+ watcher.stop();
rmSync(tempRoot, { recursive: true, force: true });
resolveExit(code);
};
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
new file mode 100644
index 00000000..b17f87d6
--- /dev/null
+++ b/src/cli/commands/serve.ts
@@ -0,0 +1,284 @@
+import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, extname, join, resolve } from "node:path";
+import { detectWorkspaceRoot } from "../shared/paths";
+import { hasHelpFlag, parseArgs } from "../shared/usage";
+import { resolveEnvPairs } from "../run/env";
+import { callWorkflow } from "../exec/call";
+import {
+ loadGeneration,
+ createSourceWatcher,
+ resolveStartupPosture,
+ WATCH_INTERVAL_MS,
+ type GenerationState,
+} from "../shared/generation";
+import { ServeHandler } from "../serve/handler";
+import { createHttpServer, listen } from "../serve/server";
+import { VERSION } from "../../version";
+
+const DEFAULT_HOST = "127.0.0.1";
+const DEFAULT_PORT = 5247;
+const DEFAULT_MAX_CONCURRENT = 4;
+
+const SERVE_USAGE =
+ "Usage: jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... \n\n" +
+ "Serve the file's workflows as an HTTP API with a generated OpenAPI 3.1 document\n" +
+ "and an embedded Swagger UI. Anything that speaks HTTP can invoke tested workflows\n" +
+ "and inspect their runs.\n\n" +
+ "Exposure mirrors `jaiph mcp`: `export workflow` declarations if any exist, otherwise\n" +
+ "every top-level workflow except channel route targets; `default` is exposed only\n" +
+ "when it is the only workflow, named after the file's basename. Descriptions come\n" +
+ "from the `#` comment lines above each workflow. Sources are re-validated on change.\n\n" +
+ "Endpoints: GET /docs (Swagger UI), GET /openapi.json, GET /healthz, GET /v1/workflows,\n" +
+ "POST /v1/workflows/{name}/runs (async 202 or ?wait=true for 200), GET /v1/runs,\n" +
+ "GET /v1/runs/{id}, POST /v1/runs/{id}/cancel.\n\n" +
+ "Auth: set JAIPH_SERVE_TOKEN to require `Authorization: Bearer ` on every\n" +
+ "/v1/* request (/healthz, /openapi.json, /docs stay open). Binding a non-loopback\n" +
+ "host without the token set is a startup error. Cap concurrent runs with\n" +
+ "JAIPH_SERVE_MAX_CONCURRENT (default 4).\n\n" +
+ " --host listen address (default: 127.0.0.1)\n" +
+ " --port listen port (default: 5247)\n" +
+ " --workspace workspace root for import resolution (default: auto-detect)\n" +
+ " --env KEY=VALUE define KEY in every run's env (repeatable); --env KEY forwards the host value.\n" +
+ " -h, --help show this help\n\n" +
+ "Example:\n" +
+ " JAIPH_SERVE_TOKEN=secret jaiph serve --host 0.0.0.0 ./tools.jh\n";
+
+const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"]);
+
+function isLoopbackHost(host: string): boolean {
+ return LOOPBACK_HOSTS.has(host.toLowerCase());
+}
+
+/** One in-flight generation: its state, live-run refcount, and superseded flag. */
+interface LiveGeneration {
+ state: GenerationState;
+ refs: number;
+ superseded: boolean;
+}
+
+export async function runServe(rest: string[]): Promise {
+ if (hasHelpFlag(rest)) {
+ process.stdout.write(SERVE_USAGE);
+ return 0;
+ }
+ let parsed: ReturnType;
+ try {
+ parsed = parseArgs(rest);
+ } catch (err) {
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
+ return 1;
+ }
+ const { workspace, env, positional, host: hostArg, port: portArg } = parsed;
+ const input = positional[0];
+ if (!input) {
+ process.stderr.write("jaiph serve requires a .jh file path\n");
+ return 1;
+ }
+ let extraEnv: Record;
+ try {
+ extraEnv = resolveEnvPairs(env, process.env);
+ } catch (err) {
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
+ return 1;
+ }
+ const inputAbs = resolve(input);
+ if (!existsSync(inputAbs) || !statSync(inputAbs).isFile() || extname(inputAbs) !== ".jh") {
+ process.stderr.write("jaiph serve expects a single .jh file\n");
+ return 1;
+ }
+ const workspaceRoot = workspace ? resolve(workspace) : detectWorkspaceRoot(dirname(inputAbs));
+ if (workspace && (!existsSync(workspaceRoot) || !statSync(workspaceRoot).isDirectory())) {
+ process.stderr.write(`--workspace path is not a directory: ${workspaceRoot}\n`);
+ return 1;
+ }
+
+ const host = hostArg ?? DEFAULT_HOST;
+ const port = portArg === undefined ? DEFAULT_PORT : Number(portArg);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ process.stderr.write(`--port must be an integer between 0 and 65535, got "${portArg}"\n`);
+ return 1;
+ }
+
+ const token = process.env.JAIPH_SERVE_TOKEN;
+ // Fail closed on exposure: a non-loopback bind without a token is a startup
+ // error, before any socket is opened.
+ if (!isLoopbackHost(host) && !token) {
+ process.stderr.write(
+ `jaiph serve: refusing to bind non-loopback host "${host}" without JAIPH_SERVE_TOKEN set ` +
+ "(every /v1/* endpoint would be unauthenticated arbitrary shell). Set JAIPH_SERVE_TOKEN and retry.\n",
+ );
+ return 1;
+ }
+
+ const maxRaw = process.env.JAIPH_SERVE_MAX_CONCURRENT;
+ let maxConcurrent = DEFAULT_MAX_CONCURRENT;
+ if (maxRaw !== undefined) {
+ const n = Number(maxRaw);
+ if (!Number.isInteger(n) || n < 1) {
+ process.stderr.write(`JAIPH_SERVE_MAX_CONCURRENT must be a positive integer, got "${maxRaw}"\n`);
+ return 1;
+ }
+ maxConcurrent = n;
+ }
+
+ // All logs go to stderr — stdout stays clean for scripting.
+ const log = (line: string): void => {
+ process.stderr.write(`${line}\n`);
+ };
+
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-serve-"));
+ let generation = 0;
+ let current: LiveGeneration;
+ try {
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve");
+ if (!loaded.state) {
+ for (const f of loaded.failures) log(f);
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+ current = { state: loaded.state, refs: 0, superseded: false };
+ } catch (err) {
+ log(err instanceof Error ? err.message : String(err));
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+
+ let dockerConfig: ReturnType["dockerConfig"];
+ try {
+ const posture = resolveStartupPosture(current.state, inputAbs, workspaceRoot, log);
+ dockerConfig = posture.dockerConfig;
+ if (dockerConfig.enabled) {
+ if (posture.sandboxMode === "inplace") {
+ log(
+ `jaiph serve: runs execute in a Docker sandbox in-place on ${workspaceRoot} ` +
+ "(JAIPH_INPLACE=1 opt-in: effects land live on the workspace).",
+ );
+ } else {
+ log(`jaiph serve: runs execute in a Docker sandbox (${posture.sandboxMode} mode; workspace isolated).`);
+ }
+ } else {
+ log("jaiph serve: runs execute on the host with no sandbox.");
+ }
+ } catch (err) {
+ log(err instanceof Error ? err.message : String(err));
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+
+ // Delete a superseded generation's out dir only once its in-flight runs finish
+ // — HTTP runs can outlive a reload (unlike MCP, where the client blocks).
+ const maybeDeleteGeneration = (gen: LiveGeneration): void => {
+ if (gen.superseded && gen.refs === 0) {
+ rmSync(gen.state.callEnv.outDir, { recursive: true, force: true });
+ }
+ };
+
+ // Track in-flight run promises so shutdown can drain them.
+ const inFlightRuns = new Set>();
+
+ const handler = new ServeHandler({
+ version: VERSION,
+ serverTitle: `jaiph — ${basename(inputAbs)}`,
+ token,
+ maxConcurrent,
+ now: () => new Date().toISOString(),
+ getTools: () => current.state.tools,
+ callTool: (spec, args, runId, ctx) => {
+ // Bind the run to the generation live at start; keep its scripts dir alive
+ // until the run finishes, then delete it if the generation was superseded.
+ const gen = current;
+ gen.refs += 1;
+ const p = callWorkflow(
+ gen.state.callEnv,
+ dockerConfig,
+ spec.workflow,
+ spec.params.map((pp) => args[pp] ?? ""),
+ runId,
+ ctx,
+ ).finally(() => {
+ gen.refs -= 1;
+ maybeDeleteGeneration(gen);
+ });
+ const tracked = p.then(
+ () => undefined,
+ () => undefined,
+ );
+ inFlightRuns.add(tracked);
+ void tracked.finally(() => inFlightRuns.delete(tracked));
+ return p;
+ },
+ });
+
+ // Hot reload: swap the current generation; per-request OpenAPI + tool reads
+ // pick it up with no cache to invalidate. Validation failures keep serving.
+ let reloading = false;
+ const onSourceChange = (): void => {
+ if (reloading) return;
+ reloading = true;
+ try {
+ generation += 1;
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve");
+ if (!loaded.state) {
+ log("jaiph serve: reload failed; keeping the previous workflows:");
+ for (const f of loaded.failures) log(` ${f}`);
+ return;
+ }
+ const prev = current;
+ current = { state: loaded.state, refs: 0, superseded: false };
+ watcher.rewatch([...current.state.graph.modules.keys()]);
+ log(`jaiph serve: sources reloaded (${current.state.tools.length} workflow(s))`);
+ prev.superseded = true;
+ maybeDeleteGeneration(prev);
+ } catch (err) {
+ log(`jaiph serve: reload failed; keeping the previous workflows: ${err instanceof Error ? err.message : String(err)}`);
+ } finally {
+ reloading = false;
+ }
+ };
+ const watcher = createSourceWatcher(WATCH_INTERVAL_MS, onSourceChange);
+ watcher.rewatch([...current.state.graph.modules.keys()]);
+
+ const httpServer = createHttpServer(handler, log);
+ let boundPort: number;
+ try {
+ boundPort = await listen(httpServer, host, port);
+ } catch (err) {
+ log(`jaiph serve: failed to listen on ${host}:${port}: ${err instanceof Error ? err.message : String(err)}`);
+ watcher.stop();
+ rmSync(tempRoot, { recursive: true, force: true });
+ return 1;
+ }
+
+ const base = `http://${host}:${boundPort}`;
+ log(`jaiph serve: listening on ${base} — API docs at ${base}/docs (${current.state.tools.length} workflow(s))`);
+
+ return await new Promise((resolveExit) => {
+ let draining = false;
+ let settled = false;
+ const finish = (code: number): void => {
+ if (settled) return;
+ settled = true;
+ process.removeListener("SIGINT", onSignal);
+ process.removeListener("SIGTERM", onSignal);
+ watcher.stop();
+ httpServer.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ resolveExit(code);
+ };
+ const onSignal = (): void => {
+ if (!draining) {
+ draining = true;
+ log("jaiph serve: shutting down; draining in-flight runs (signal again to cancel them)...");
+ httpServer.close();
+ watcher.stop();
+ void Promise.allSettled([...inFlightRuns]).then(() => finish(0));
+ } else {
+ log("jaiph serve: cancelling in-flight runs...");
+ handler.cancelAll();
+ }
+ };
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+ });
+}
diff --git a/src/cli/mcp/call.ts b/src/cli/exec/call.ts
similarity index 80%
rename from src/cli/mcp/call.ts
rename to src/cli/exec/call.ts
index 3babcb60..f712912c 100644
--- a/src/cli/mcp/call.ts
+++ b/src/cli/exec/call.ts
@@ -1,5 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
-import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { ChildProcess } from "node:child_process";
import type { JaiphConfig } from "../../config";
@@ -17,14 +16,42 @@ import {
type DockerRunConfig,
} from "../../runtime/docker";
import { discoverDockerRunDir, remapContainerPath } from "../shared/errors";
-import type { McpCallResult, McpCallContext } from "./server";
/**
- * Everything a `tools/call` needs from the server session. Built once per
+ * Result of executing one workflow call. `text` is the same content an MCP
+ * client sees (`composeResult`); `isError` is true when the workflow failed.
+ * The `runDir` / `exitStatus` / `signal` fields let HTTP callers (`jaiph serve`)
+ * populate a durable run object — MCP ignores them.
+ */
+export interface WorkflowCallResult {
+ /** Text returned to the caller as the call result. */
+ text: string;
+ /** True when the workflow failed; surfaces as `isError` on the result. */
+ isError: boolean;
+ /** Absolute run directory under `.jaiph/runs/`, when discoverable. */
+ runDir?: string;
+ /** Child exit status (0 on success). */
+ exitStatus?: number;
+ /** Terminating signal, when the child was killed. */
+ signal?: NodeJS.Signals | null;
+}
+
+/**
+ * Live hooks handed to `callWorkflow` for one in-flight call. `onStep` fires per
+ * `STEP_START`/`STEP_END` event; the executor registers its child-termination
+ * function via `onCancelHandle` so a cancellation can kill the run.
+ */
+export interface WorkflowCallContext {
+ onStep?: (kind: string, name: string) => void;
+ onCancelHandle?: (cancel: () => void) => void;
+}
+
+/**
+ * Everything a workflow call needs from the server session. Built once per
* module-graph generation (startup and each hot reload) — scripts and the
* serialized graph are read-only, so concurrent calls can share them.
*/
-export interface McpCallEnvironment {
+export interface WorkflowCallEnvironment {
inputAbs: string;
workspaceRoot: string;
/**
@@ -41,7 +68,7 @@ export interface McpCallEnvironment {
/** Generation dir for per-call meta files. */
outDir: string;
/**
- * Resolved `--env` passthrough applied to every tool call for the server's
+ * Resolved `--env` passthrough applied to every call for the server's
* lifetime. Host execution merges it into the runner env; Docker execution
* threads it through `DockerSpawnOptions.extraEnv` — this is the single
* choke point either way.
@@ -58,23 +85,26 @@ interface CollectedOutput {
}
/**
- * Execute one workflow as an MCP tool call. Honors the same env-driven sandbox
- * selection as `jaiph run`: when `dockerConfig.enabled`, the call runs in a
- * per-call container (workspace isolated by default; inplace when JAIPH_INPLACE=1);
+ * Execute one workflow call. Honors the same env-driven sandbox selection as
+ * `jaiph run`: when `dockerConfig.enabled`, the call runs in a per-call
+ * container (workspace isolated by default; inplace when JAIPH_INPLACE=1);
* otherwise it runs on the host like `jaiph run --raw`.
*
+ * The caller supplies `runId` so it can register the run before the child
+ * exits (the HTTP server needs the id while the run is still `running`).
+ *
* Success text, in order of preference: the workflow's return value
* (`return_value.txt`), collected `log` output, or a completion note.
*/
export async function callWorkflow(
- env: McpCallEnvironment,
+ env: WorkflowCallEnvironment,
dockerConfig: DockerRunConfig,
workflowSymbol: string,
positionalArgs: string[],
- ctx?: McpCallContext,
-): Promise {
+ runId: string,
+ ctx?: WorkflowCallContext,
+): Promise {
const runtimeEnv = resolveRuntimeEnv(env.effectiveConfig, env.workspaceRoot, env.inputAbs);
- const runId = randomUUID();
runtimeEnv.JAIPH_SOURCE_ABS = env.inputAbs;
runtimeEnv.JAIPH_RUN_ID = runId;
runtimeEnv.JAIPH_SCRIPTS = env.scriptsDir;
@@ -87,13 +117,13 @@ export async function callWorkflow(
/** Host execution — same self-spawn path as `jaiph run --raw`. */
async function callWorkflowHost(
- env: McpCallEnvironment,
+ env: WorkflowCallEnvironment,
workflowSymbol: string,
positionalArgs: string[],
runtimeEnv: Record,
runId: string,
- ctx?: McpCallContext,
-): Promise {
+ ctx?: WorkflowCallContext,
+): Promise {
runtimeEnv.JAIPH_MODULE_GRAPH_FILE = env.graphFile;
// `--env` passthrough defines the workflow process's env, overriding
// inherited values, on every call.
@@ -117,20 +147,20 @@ async function callWorkflowHost(
/**
* Container execution — the same Docker path as `jaiph run`. The workflow
- * symbol is carried into the container so a non-`default` tool runs correctly.
+ * symbol is carried into the container so a non-`default` call runs correctly.
* Sandbox mode matches `jaiph run` (isolated by default; inplace when
* JAIPH_INPLACE=1). The container meta file is inaccessible from the host, so
* the run dir is discovered from the sandbox runs mount.
*/
async function callWorkflowDocker(
- env: McpCallEnvironment,
+ env: WorkflowCallEnvironment,
dockerConfig: DockerRunConfig,
workflowSymbol: string,
positionalArgs: string[],
runtimeEnv: Record,
runId: string,
- ctx?: McpCallContext,
-): Promise {
+ ctx?: WorkflowCallContext,
+): Promise {
const sandboxMode = selectMcpSandboxMode(runtimeEnv);
const sandboxRunDir = resolveDockerHostRunsRoot(env.workspaceRoot, runtimeEnv);
const dockerResult = spawnDockerProcess({
@@ -220,14 +250,14 @@ function attachOutputCollector(
};
}
-/** Compose the MCP result text from a finished run's output + run dir. */
+/** Compose the call result text from a finished run's output + run dir. */
function composeResult(
workflowSymbol: string,
data: CollectedOutput,
exit: { status: number; signal: NodeJS.Signals | null },
runDir: string | undefined,
sandboxRunDir: string | undefined,
-): McpCallResult {
+): WorkflowCallResult {
const failed = exit.status !== 0 || exit.signal !== null;
if (!failed) {
@@ -238,7 +268,7 @@ function composeResult(
: data.logs.length > 0
? data.logs.join("\n")
: `workflow ${workflowSymbol} completed`;
- return { text: trimTrailingNewline(text), isError: false };
+ return { text: trimTrailingNewline(text), isError: false, runDir, exitStatus: exit.status, signal: exit.signal };
}
const parts: string[] = [];
@@ -257,7 +287,7 @@ function composeResult(
if (!data.failedStep && !stderrTrimmed && stdoutTrimmed) parts.push(stdoutTrimmed);
if (data.logs.length > 0) parts.push(`log output:\n${data.logs.join("\n")}`);
if (runDir) parts.push(`run dir: ${runDir}`);
- return { text: parts.join("\n\n"), isError: true };
+ return { text: parts.join("\n\n"), isError: true, runDir, exitStatus: exit.status, signal: exit.signal };
}
function readMetaFile(metaFile: string): { runDir?: string; summaryFile?: string } {
diff --git a/src/cli/index.ts b/src/cli/index.ts
index d7b18122..cfed8f1b 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -9,6 +9,7 @@ import { runFormat } from "./commands/format";
import { runInstall } from "./commands/install";
import { runCompile } from "./commands/compile";
import { runMcp } from "./commands/mcp";
+import { runServe } from "./commands/serve";
import { runWorkflowRunner, WORKFLOW_RUNNER_ARG } from "../runtime/kernel/node-workflow-runner";
import { VERSION } from "../version";
@@ -67,6 +68,9 @@ export async function main(argv: string[]): Promise {
if (cmd === "mcp" || cmd === "--mcp") {
return await runMcp(rest);
}
+ if (cmd === "serve") {
+ return await runServe(rest);
+ }
process.stderr.write(`Unknown command: ${cmd}\n`);
printUsage();
return 1;
diff --git a/src/cli/serve/docs.test.ts b/src/cli/serve/docs.test.ts
new file mode 100644
index 00000000..aee212f1
--- /dev/null
+++ b/src/cli/serve/docs.test.ts
@@ -0,0 +1,38 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { DOCS_HTML, SWAGGER_UI_VERSION } from "./docs";
+
+test("the /docs shell pins an exact swagger-ui-dist version on both assets", () => {
+ // The version constant is an exact pin (no ^/~/latest), and both asset URLs
+ // embed it.
+ assert.match(SWAGGER_UI_VERSION, /^\d+\.\d+\.\d+$/);
+ assert.ok(
+ DOCS_HTML.includes(`swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`),
+ "bundle URL pins the exact version",
+ );
+ assert.ok(
+ DOCS_HTML.includes(`swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`),
+ "css URL pins the exact version",
+ );
+ assert.ok(!/swagger-ui-dist@(latest|\^|~)/.test(DOCS_HTML), "no floating version range");
+});
+
+test("both the JS and CSS assets carry integrity + crossorigin attributes", () => {
+ // Extract each "));
+ const cssTag = DOCS_HTML.slice(DOCS_HTML.indexOf("", DOCS_HTML.indexOf(" {
+ assert.match(DOCS_HTML, /SwaggerUIBundle\(/);
+ assert.match(DOCS_HTML, /url:\s*"\/openapi\.json"/);
+ assert.match(DOCS_HTML, /persistAuthorization:\s*true/);
+});
diff --git a/src/cli/serve/docs.ts b/src/cli/serve/docs.ts
new file mode 100644
index 00000000..cdbd4548
--- /dev/null
+++ b/src/cli/serve/docs.ts
@@ -0,0 +1,51 @@
+// Swagger UI is loaded from a CDN with a pinned exact version, Subresource
+// Integrity (SRI) hashes, and crossorigin — never vendored/embedded, so the
+// jaiph binary stays lean (embedding swagger-ui is ~1.5 MB for one page). The
+// consequence, documented in docs/serve.md and the design doc: `/docs` needs
+// internet access in the browser; air-gapped operators still have
+// `/openapi.json`, which any locally-hosted Swagger/Redoc/Scalar renders.
+//
+// To bump the version: change SWAGGER_UI_VERSION and regenerate both hashes:
+// curl -s https://cdn.jsdelivr.net/npm/swagger-ui-dist@/swagger-ui-bundle.js \
+// | openssl dgst -sha384 -binary | openssl base64 -A
+// curl -s https://cdn.jsdelivr.net/npm/swagger-ui-dist@/swagger-ui.css \
+// | openssl dgst -sha384 -binary | openssl base64 -A
+export const SWAGGER_UI_VERSION = "5.17.14";
+const CDN_BASE = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}`;
+const BUNDLE_SRI = "sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep";
+const CSS_SRI = "sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn";
+
+/**
+ * Static Swagger UI HTML shell. Loads `swagger-ui-dist` from the CDN (pinned +
+ * SRI + crossorigin) and points it at `/openapi.json`. `persistAuthorization`
+ * keeps the bearer token entered in the Authorize box across reloads, since a
+ * browser cannot attach headers to the initial `/docs` navigation.
+ */
+export const DOCS_HTML = `
+
+
+
+
+ jaiph serve — API
+
+
+
+
+
+
+
+
+`;
diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts
new file mode 100644
index 00000000..9453326f
--- /dev/null
+++ b/src/cli/serve/handler.test.ts
@@ -0,0 +1,292 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ServeHandler, type ServeRequest, type ServeResponse } from "./handler";
+import type { McpToolSpec } from "../mcp/tools";
+import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
+
+const BUILD_TOOL: McpToolSpec = {
+ name: "build",
+ workflow: "build",
+ description: "Builds the target.",
+ params: ["target"],
+ inputSchema: {
+ type: "object",
+ properties: { target: { type: "string" } },
+ required: ["target"],
+ additionalProperties: false,
+ },
+};
+
+const NOARG_TOOL: McpToolSpec = {
+ name: "ping",
+ workflow: "ping",
+ description: "Pings.",
+ params: [],
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+};
+
+type CallTool = (
+ spec: McpToolSpec,
+ args: Record,
+ runId: string,
+ ctx: WorkflowCallContext,
+) => Promise;
+
+function makeHandler(overrides?: {
+ callTool?: CallTool;
+ tools?: McpToolSpec[];
+ token?: string;
+ maxConcurrent?: number;
+}): ServeHandler {
+ let n = 0;
+ return new ServeHandler({
+ version: "0.0.0-test",
+ serverTitle: "jaiph — test.jh",
+ getTools: () => overrides?.tools ?? [BUILD_TOOL],
+ callTool: overrides?.callTool ?? (async () => ({ text: "done", isError: false, exitStatus: 0 })),
+ token: overrides?.token,
+ maxConcurrent: overrides?.maxConcurrent ?? 4,
+ now: () => "2026-07-24T00:00:00.000Z",
+ newRunId: () => `run-${n++}`,
+ });
+}
+
+function req(method: string, path: string, opts?: { headers?: Record; body?: string; bodyTooLarge?: boolean }): ServeRequest {
+ const url = new URL(path, "http://x");
+ return {
+ method,
+ path: url.pathname,
+ query: url.searchParams,
+ headers: opts?.headers ?? {},
+ body: opts?.body ?? "",
+ bodyTooLarge: opts?.bodyTooLarge,
+ };
+}
+
+function bodyJson(res: ServeResponse): any {
+ return JSON.parse(res.body);
+}
+
+const flush = (): Promise => new Promise((r) => setImmediate(r));
+
+// === routing / unauthenticated surface ===
+
+test("GET / redirects to /docs", async () => {
+ const res = await makeHandler().handleRequest(req("GET", "/"));
+ assert.equal(res.status, 302);
+ assert.equal(res.headers.location, "/docs");
+});
+
+test("GET /healthz reports status, version, tools, in_flight (unauthenticated)", async () => {
+ const res = await makeHandler({ token: "secret" }).handleRequest(req("GET", "/healthz"));
+ assert.equal(res.status, 200);
+ const body = bodyJson(res);
+ assert.equal(body.status, "ok");
+ assert.equal(body.version, "0.0.0-test");
+ assert.equal(body.tools, 1);
+ assert.equal(body.in_flight, 0);
+});
+
+test("GET /openapi.json and /docs answer 200 unauthenticated even with a token set", async () => {
+ const h = makeHandler({ token: "secret" });
+ const openapi = await h.handleRequest(req("GET", "/openapi.json"));
+ assert.equal(openapi.status, 200);
+ assert.equal(bodyJson(openapi).openapi, "3.1.0");
+ const docs = await h.handleRequest(req("GET", "/docs"));
+ assert.equal(docs.status, 200);
+ assert.match(docs.headers["content-type"], /text\/html/);
+});
+
+test("wrong method on a known path is 405", async () => {
+ const res = await makeHandler().handleRequest(req("POST", "/healthz"));
+ assert.equal(res.status, 405);
+ assert.equal(bodyJson(res).error.code, "E_METHOD_NOT_ALLOWED");
+});
+
+// === auth matrix (token set) ===
+
+test("auth matrix: /v1/* requires the bearer token when JAIPH_SERVE_TOKEN is set", async () => {
+ const h = makeHandler({ token: "secret" });
+ const none = await h.handleRequest(req("GET", "/v1/workflows"));
+ assert.equal(none.status, 401);
+ assert.equal(bodyJson(none).error.code, "E_UNAUTHORIZED");
+
+ const wrong = await h.handleRequest(req("GET", "/v1/workflows", { headers: { authorization: "Bearer nope" } }));
+ assert.equal(wrong.status, 401);
+
+ const right = await h.handleRequest(req("GET", "/v1/workflows", { headers: { authorization: "Bearer secret" } }));
+ assert.equal(right.status, 200);
+ assert.deepEqual(bodyJson(right).workflows, [{ name: "build", description: "Builds the target.", params: ["target"] }]);
+});
+
+test("with no token configured, /v1/* is open (loopback default)", async () => {
+ const res = await makeHandler().handleRequest(req("GET", "/v1/workflows"));
+ assert.equal(res.status, 200);
+});
+
+// === POST run: validation ===
+
+test("POST run for an unknown workflow is 404", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/nope/runs", { headers: { "content-type": "application/json" }, body: "{}" }),
+ );
+ assert.equal(res.status, 404);
+ assert.equal(bodyJson(res).error.code, "E_NOT_FOUND");
+});
+
+test("POST run with a missing required param is 400", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/build/runs", { headers: { "content-type": "application/json" }, body: "{}" }),
+ );
+ assert.equal(res.status, 400);
+ assert.equal(bodyJson(res).error.code, "E_BAD_ARGS");
+ assert.match(bodyJson(res).error.message, /target/);
+});
+
+test("POST run with a non-string param is 400", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/build/runs", { headers: { "content-type": "application/json" }, body: JSON.stringify({ target: 5 }) }),
+ );
+ assert.equal(res.status, 400);
+ assert.match(bodyJson(res).error.message, /target/);
+});
+
+test("POST run with an unexpected param key is 400", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/build/runs", {
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ target: "x", bogus: "y" }),
+ }),
+ );
+ assert.equal(res.status, 400);
+ assert.match(bodyJson(res).error.message, /bogus/);
+});
+
+test("POST run with a non-JSON content type is 415", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/build/runs", { headers: { "content-type": "text/plain" }, body: "target=x" }),
+ );
+ assert.equal(res.status, 415);
+ assert.equal(bodyJson(res).error.code, "E_UNSUPPORTED_MEDIA_TYPE");
+});
+
+test("POST run past the body cap is 413", async () => {
+ const res = await makeHandler().handleRequest(
+ req("POST", "/v1/workflows/build/runs", { headers: { "content-type": "application/json" }, bodyTooLarge: true }),
+ );
+ assert.equal(res.status, 413);
+ assert.equal(bodyJson(res).error.code, "E_BODY_TOO_LARGE");
+});
+
+test("concurrency cap returns 429 beyond the limit", async () => {
+ // callTool never resolves, so the first run stays in-flight and the second is
+ // rejected by the cap of 1.
+ const h = makeHandler({ maxConcurrent: 1, callTool: () => new Promise(() => {}) });
+ const first = await h.handleRequest(req("POST", "/v1/workflows/ping/runs"));
+ // ping has no params; empty body is fine.
+ const hp = makeHandler({ maxConcurrent: 1, tools: [NOARG_TOOL], callTool: () => new Promise(() => {}) });
+ const a = await hp.handleRequest(req("POST", "/v1/workflows/ping/runs"));
+ assert.equal(a.status, 202);
+ const b = await hp.handleRequest(req("POST", "/v1/workflows/ping/runs"));
+ assert.equal(b.status, 429);
+ assert.equal(bodyJson(b).error.code, "E_TOO_MANY_RUNS");
+ void first;
+});
+
+// === async vs wait ===
+
+test("async POST returns 202 with a Location header and a running run object", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], callTool: () => new Promise(() => {}) });
+ const res = await h.handleRequest(req("POST", "/v1/workflows/ping/runs"));
+ assert.equal(res.status, 202);
+ const body = bodyJson(res);
+ assert.equal(body.status, "running");
+ assert.equal(res.headers.location, `/v1/runs/${body.run_id}`);
+});
+
+test("?wait=true returns 200 with the terminal run object and result_text", async () => {
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ callTool: async () => ({ text: "hello world", isError: false, exitStatus: 0, runDir: "/runs/x" }),
+ });
+ const res = await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ assert.equal(res.status, 200);
+ const body = bodyJson(res);
+ assert.equal(body.status, "succeeded");
+ assert.equal(body.result_text, "hello world");
+ assert.equal(body.run_dir, "/runs/x");
+});
+
+test("a workflow failure is not an HTTP error: 200 with status failed and exit_status", async () => {
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ callTool: async () => ({ text: "workflow ping failed (exit 1)\n\nrun dir: /runs/x", isError: true, exitStatus: 1, runDir: "/runs/x" }),
+ });
+ const res = await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ assert.equal(res.status, 200);
+ const body = bodyJson(res);
+ assert.equal(body.status, "failed");
+ assert.equal(body.exit_status, 1);
+ assert.match(body.result_text, /run dir:/);
+});
+
+// === run inspection ===
+
+test("GET /v1/runs/{id} for an unknown id is 404, and lists newest first", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }) });
+ const notFound = await h.handleRequest(req("GET", "/v1/runs/does-not-exist"));
+ assert.equal(notFound.status, 404);
+
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ const list = bodyJson(await h.handleRequest(req("GET", "/v1/runs")));
+ assert.equal(list.runs.length, 2);
+ // newest first: run-1 then run-0
+ assert.equal(list.runs[0].run_id, "run-1");
+ assert.equal(list.runs[1].run_id, "run-0");
+});
+
+// === cancellation ===
+
+test("cancel: 202 then terminal cancelled, invoking child + container teardown", async () => {
+ let childKilled = false;
+ let containerStopped = false;
+ let runPromise!: Promise;
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ callTool: (_spec, _args, _runId, ctx) => {
+ runPromise = new Promise((resolve) => {
+ ctx.onCancelHandle?.(() => {
+ childKilled = true;
+ containerStopped = true;
+ resolve({ text: "terminated by signal SIGINT", isError: true, exitStatus: 1, signal: "SIGINT" });
+ });
+ });
+ return runPromise;
+ },
+ });
+ const start = await h.handleRequest(req("POST", "/v1/workflows/ping/runs"));
+ assert.equal(start.status, 202);
+ const runId = bodyJson(start).run_id;
+
+ const cancel = await h.handleRequest(req("POST", `/v1/runs/${runId}/cancel`));
+ assert.equal(cancel.status, 202);
+ assert.equal(childKilled, true, "child terminator ran");
+ assert.equal(containerStopped, true, "container teardown ran");
+
+ await runPromise.catch(() => {});
+ await flush();
+ const record = bodyJson(await h.handleRequest(req("GET", `/v1/runs/${runId}`)));
+ assert.equal(record.status, "cancelled");
+});
+
+test("cancel on an unknown run is 404; cancel on a terminal run is 409", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }) });
+ const missing = await h.handleRequest(req("POST", "/v1/runs/nope/cancel"));
+ assert.equal(missing.status, 404);
+
+ const start = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true")));
+ const again = await h.handleRequest(req("POST", `/v1/runs/${start.run_id}/cancel`));
+ assert.equal(again.status, 409);
+ assert.equal(bodyJson(again).error.code, "E_RUN_TERMINAL");
+});
diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts
new file mode 100644
index 00000000..7f89fef3
--- /dev/null
+++ b/src/cli/serve/handler.ts
@@ -0,0 +1,342 @@
+import { randomUUID, timingSafeEqual } from "node:crypto";
+import type { McpToolSpec } from "../mcp/tools";
+import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
+import { buildOpenApi } from "./openapi";
+import { DOCS_HTML } from "./docs";
+
+/** 1 MiB cap on request bodies (design doc). */
+export const MAX_BODY_BYTES = 1024 * 1024;
+
+export type RunStatus = "running" | "succeeded" | "failed" | "cancelled";
+
+/** In-memory record for one run: the public run object plus cancel bookkeeping. */
+export interface RunRecord {
+ run_id: string;
+ workflow: string;
+ status: RunStatus;
+ started_at: string;
+ ended_at: string | null;
+ exit_status: number | null;
+ signal: string | null;
+ result_text: string | null;
+ run_dir: string | null;
+ /** Set once a cancel is requested, so terminal status resolves to `cancelled`. */
+ cancelled: boolean;
+ /** Child terminator registered by the executor; kills the run + container. */
+ cancel?: () => void;
+ /** Monotonic insertion index for newest-first listing. */
+ order: number;
+}
+
+/** A normalized inbound request — decoupled from `node:http` so it is unit-testable. */
+export interface ServeRequest {
+ method: string;
+ /** Pathname without the query string. */
+ path: string;
+ query: URLSearchParams;
+ headers: Record;
+ /** Decoded request body (empty string when none). */
+ body: string;
+ /** True when the HTTP layer aborted reading past `MAX_BODY_BYTES`. */
+ bodyTooLarge?: boolean;
+}
+
+/** A normalized response the HTTP layer writes back. */
+export interface ServeResponse {
+ status: number;
+ headers: Record;
+ body: string;
+}
+
+export interface ServeHandlerOptions {
+ version: string;
+ /** `info.title` for the generated OpenAPI document. */
+ serverTitle: string;
+ /** Current tool list (re-read per request so hot reload just works). */
+ getTools: () => McpToolSpec[];
+ /** Execute one workflow. The caller supplies `runId`; `ctx` carries cancel. */
+ callTool: (
+ spec: McpToolSpec,
+ args: Record,
+ runId: string,
+ ctx: WorkflowCallContext,
+ ) => Promise;
+ /** Bearer token; when set, every `/v1/*` request must present it. */
+ token?: string;
+ /** Cap on simultaneously-running workflows (429 beyond it). */
+ maxConcurrent: number;
+ /** Current-time source (ISO string), injectable for tests. */
+ now: () => string;
+ /** Run-id source, injectable for tests. Defaults to `randomUUID`. */
+ newRunId?: () => string;
+}
+
+function isTerminal(status: RunStatus): boolean {
+ return status === "succeeded" || status === "failed" || status === "cancelled";
+}
+
+/**
+ * HTTP request router for `jaiph serve`. A pure request-in / response-out state
+ * machine over an injected execution layer + run registry (the `McpServer`
+ * pattern), so the whole surface — auth, arg validation, error shapes, wait
+ * semantics, cancel, the concurrency cap, and generated OpenAPI — is unit
+ * testable without opening a socket. The `node:http` glue (`server.ts`) only
+ * reads the body and streams this response back.
+ */
+export class ServeHandler {
+ private readonly opts: ServeHandlerOptions;
+ private readonly newRunId: () => string;
+ /** In-memory run registry, keyed by run id. Public so tests can inspect it. */
+ readonly runs = new Map();
+ private orderCounter = 0;
+
+ constructor(opts: ServeHandlerOptions) {
+ this.opts = opts;
+ this.newRunId = opts.newRunId ?? randomUUID;
+ }
+
+ /** Number of runs still executing (drives the concurrency cap + healthz). */
+ inFlight(): number {
+ let n = 0;
+ for (const r of this.runs.values()) if (r.status === "running") n += 1;
+ return n;
+ }
+
+ /** Cancel every still-running run (second-signal shutdown: child + container teardown). */
+ cancelAll(): void {
+ for (const record of this.runs.values()) {
+ if (record.status === "running") {
+ record.cancelled = true;
+ record.cancel?.();
+ }
+ }
+ }
+
+ async handleRequest(req: ServeRequest): Promise {
+ const { method, path } = req;
+
+ if (path === "/" && method === "GET") {
+ return { status: 302, headers: { location: "/docs" }, body: "" };
+ }
+ if (path === "/healthz") {
+ if (method !== "GET") return this.methodNotAllowed();
+ return this.json(200, {
+ status: "ok",
+ version: this.opts.version,
+ tools: this.opts.getTools().length,
+ in_flight: this.inFlight(),
+ });
+ }
+ if (path === "/openapi.json") {
+ if (method !== "GET") return this.methodNotAllowed();
+ return this.json(200, buildOpenApi(this.opts.getTools(), { title: this.opts.serverTitle, version: this.opts.version }));
+ }
+ if (path === "/docs") {
+ if (method !== "GET") return this.methodNotAllowed();
+ return { status: 200, headers: { "content-type": "text/html; charset=utf-8" }, body: DOCS_HTML };
+ }
+
+ // Everything under /v1 is bearer-protected (when a token is configured).
+ if (path === "/v1" || path.startsWith("/v1/")) {
+ if (!this.authorized(req)) {
+ return this.error(401, "E_UNAUTHORIZED", "missing or invalid bearer token");
+ }
+ return this.handleV1(req);
+ }
+
+ return this.error(404, "E_NOT_FOUND", `not found: ${path}`);
+ }
+
+ private handleV1(req: ServeRequest): ServeResponse | Promise {
+ const { method, path } = req;
+
+ if (path === "/v1/workflows") {
+ if (method !== "GET") return this.methodNotAllowed();
+ const workflows = this.opts.getTools().map((t) => ({ name: t.name, description: t.description, params: t.params }));
+ return this.json(200, { workflows });
+ }
+
+ const runPost = /^\/v1\/workflows\/([^/]+)\/runs$/.exec(path);
+ if (runPost) {
+ if (method !== "POST") return this.methodNotAllowed();
+ return this.createRun(req, decodeURIComponent(runPost[1]));
+ }
+
+ if (path === "/v1/runs") {
+ if (method !== "GET") return this.methodNotAllowed();
+ const runs = [...this.runs.values()]
+ .sort((a, b) => b.order - a.order)
+ .map((r) => this.toRunObject(r));
+ return this.json(200, { runs });
+ }
+
+ const cancel = /^\/v1\/runs\/([^/]+)\/cancel$/.exec(path);
+ if (cancel) {
+ if (method !== "POST") return this.methodNotAllowed();
+ return this.cancelRun(decodeURIComponent(cancel[1]));
+ }
+
+ const getRun = /^\/v1\/runs\/([^/]+)$/.exec(path);
+ if (getRun) {
+ if (method !== "GET") return this.methodNotAllowed();
+ const record = this.runs.get(decodeURIComponent(getRun[1]));
+ if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
+ return this.json(200, this.toRunObject(record));
+ }
+
+ return this.error(404, "E_NOT_FOUND", `not found: ${path}`);
+ }
+
+ private async createRun(req: ServeRequest, name: string): Promise {
+ const spec = this.opts.getTools().find((t) => t.name === name);
+ if (!spec) return this.error(404, "E_NOT_FOUND", `unknown workflow: ${name}`);
+
+ if (req.bodyTooLarge) {
+ return this.error(413, "E_BODY_TOO_LARGE", `request body exceeds ${MAX_BODY_BYTES} bytes`);
+ }
+ const hasBody = req.body.length > 0;
+ if (hasBody && !isJsonContentType(req.headers["content-type"])) {
+ return this.error(415, "E_UNSUPPORTED_MEDIA_TYPE", "request body must be application/json");
+ }
+ let raw: Record;
+ if (hasBody) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(req.body);
+ } catch {
+ return this.error(400, "E_BAD_ARGS", "request body is not valid JSON");
+ }
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ return this.error(400, "E_BAD_ARGS", "request body must be a JSON object of parameters");
+ }
+ raw = parsed as Record;
+ } else {
+ raw = {};
+ }
+
+ // Param validation mirrors the MCP `-32602` rules (missing / non-string /
+ // unexpected key) as HTTP 400.
+ const missing = spec.params.filter((p) => typeof raw[p] !== "string");
+ if (missing.length > 0) {
+ return this.error(400, "E_BAD_ARGS", `missing or non-string argument(s) for "${spec.name}": ${missing.join(", ")}`);
+ }
+ const unexpected = Object.keys(raw).filter((k) => !spec.params.includes(k));
+ if (unexpected.length > 0) {
+ return this.error(400, "E_BAD_ARGS", `unexpected argument(s) for "${spec.name}": ${unexpected.join(", ")}`);
+ }
+
+ if (this.inFlight() >= this.opts.maxConcurrent) {
+ return this.error(429, "E_TOO_MANY_RUNS", `too many concurrent runs (max ${this.opts.maxConcurrent})`);
+ }
+
+ const args: Record = {};
+ for (const p of spec.params) args[p] = raw[p] as string;
+
+ const runId = this.newRunId();
+ const record: RunRecord = {
+ run_id: runId,
+ workflow: spec.workflow,
+ status: "running",
+ started_at: this.opts.now(),
+ ended_at: null,
+ exit_status: null,
+ signal: null,
+ result_text: null,
+ run_dir: null,
+ cancelled: false,
+ order: this.orderCounter++,
+ };
+ this.runs.set(runId, record);
+
+ const ctx: WorkflowCallContext = {
+ onCancelHandle: (cancelFn) => {
+ record.cancel = cancelFn;
+ // A cancel may arrive before the child spawns; honor it now.
+ if (record.cancelled) cancelFn();
+ },
+ };
+ const done = this.opts
+ .callTool(spec, args, runId, ctx)
+ .then((result) => this.finalize(record, result))
+ .catch((err) => this.finalizeError(record, err));
+
+ const wait = req.query.get("wait") === "true";
+ if (wait) {
+ await done;
+ return this.json(200, this.toRunObject(record));
+ }
+ return {
+ status: 202,
+ headers: { "content-type": "application/json", location: `/v1/runs/${runId}` },
+ body: JSON.stringify(this.toRunObject(record)),
+ };
+ }
+
+ private cancelRun(id: string): ServeResponse {
+ const record = this.runs.get(id);
+ if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
+ if (isTerminal(record.status)) {
+ return this.error(409, "E_RUN_TERMINAL", `run is already ${record.status}`);
+ }
+ record.cancelled = true;
+ record.cancel?.();
+ return { status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify(this.toRunObject(record)) };
+ }
+
+ private finalize(record: RunRecord, result: WorkflowCallResult): void {
+ record.ended_at = this.opts.now();
+ record.exit_status = result.exitStatus ?? null;
+ record.signal = result.signal ?? null;
+ record.result_text = result.text;
+ record.run_dir = result.runDir ?? null;
+ record.status = record.cancelled ? "cancelled" : result.isError ? "failed" : "succeeded";
+ }
+
+ private finalizeError(record: RunRecord, err: unknown): void {
+ record.ended_at = this.opts.now();
+ record.result_text = err instanceof Error ? err.message : String(err);
+ record.status = record.cancelled ? "cancelled" : "failed";
+ }
+
+ private authorized(req: ServeRequest): boolean {
+ if (!this.opts.token) return true;
+ const header = req.headers["authorization"];
+ if (!header) return false;
+ const match = /^Bearer\s+(.+)$/.exec(header);
+ if (!match) return false;
+ const provided = Buffer.from(match[1]);
+ const expected = Buffer.from(this.opts.token);
+ if (provided.length !== expected.length) return false;
+ return timingSafeEqual(provided, expected);
+ }
+
+ private toRunObject(r: RunRecord): Record {
+ return {
+ run_id: r.run_id,
+ workflow: r.workflow,
+ status: r.status,
+ started_at: r.started_at,
+ ended_at: r.ended_at,
+ exit_status: r.exit_status,
+ signal: r.signal,
+ result_text: r.result_text,
+ run_dir: r.run_dir,
+ };
+ }
+
+ private json(status: number, obj: unknown): ServeResponse {
+ return { status, headers: { "content-type": "application/json" }, body: JSON.stringify(obj) };
+ }
+
+ private error(status: number, code: string, message: string): ServeResponse {
+ return { status, headers: { "content-type": "application/json" }, body: JSON.stringify({ error: { code, message } }) };
+ }
+
+ private methodNotAllowed(): ServeResponse {
+ return this.error(405, "E_METHOD_NOT_ALLOWED", "method not allowed");
+ }
+}
+
+function isJsonContentType(contentType: string | undefined): boolean {
+ return typeof contentType === "string" && contentType.split(";")[0].trim().toLowerCase() === "application/json";
+}
diff --git a/src/cli/serve/openapi.test.ts b/src/cli/serve/openapi.test.ts
new file mode 100644
index 00000000..c73ce4cc
--- /dev/null
+++ b/src/cli/serve/openapi.test.ts
@@ -0,0 +1,79 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Validator } from "@seriousme/openapi-schema-validator";
+import { parsejaiph } from "../../parser";
+import { deriveTools, type McpToolSpec } from "../mcp/tools";
+import { buildOpenApi } from "./openapi";
+
+const FILE = "/ws/tools.jh";
+
+function toolsFrom(source: string): McpToolSpec[] {
+ return deriveTools(parsejaiph(source, FILE), FILE).tools;
+}
+
+const SERVER_INFO = { title: "jaiph — tools.jh", version: "9.9.9" };
+
+// An export-narrowing fixture: only `alpha` is exported, so `beta` is not a tool.
+const EXPORT_NARROWED = [
+ "# Alpha does A.",
+ "export workflow alpha(name) {",
+ ' return "a ${name}"',
+ "}",
+ "",
+ "# Beta does B (not exported).",
+ "workflow beta() {",
+ ' return "b"',
+ "}",
+ "",
+].join("\n");
+
+test("buildOpenApi produces a document that passes a real OpenAPI 3.1 validator", async () => {
+ const doc = buildOpenApi(toolsFrom(EXPORT_NARROWED), SERVER_INFO);
+ const validator = new Validator();
+ const result = await validator.validate(doc);
+ assert.equal(result.valid, true, `OpenAPI validation failed: ${JSON.stringify(result.errors)}`);
+ assert.equal(validator.version, "3.1");
+});
+
+test("buildOpenApi emits one path per exposed workflow, honoring export narrowing", () => {
+ const tools = toolsFrom(EXPORT_NARROWED);
+ assert.deepEqual(tools.map((t) => t.name), ["alpha"]);
+
+ const doc = buildOpenApi(tools, SERVER_INFO) as any;
+ const workflowPaths = Object.keys(doc.paths).filter((p) => /^\/v1\/workflows\/[^/]+\/runs$/.test(p));
+ assert.deepEqual(workflowPaths, ["/v1/workflows/alpha/runs"], "exactly one workflow path; beta is not exposed");
+ assert.equal(doc.paths["/v1/workflows/beta/runs"], undefined);
+});
+
+test("each workflow path carries the exact MCP-derived input schema as its JSON request body", () => {
+ const tools = toolsFrom(EXPORT_NARROWED);
+ const doc = buildOpenApi(tools, SERVER_INFO) as any;
+ const op = doc.paths["/v1/workflows/alpha/runs"].post;
+ assert.equal(op.operationId, "run_alpha");
+ assert.deepEqual(op.requestBody.content["application/json"].schema, tools[0].inputSchema);
+ // Bearer security is applied to the workflow operation.
+ assert.deepEqual(op.security, [{ bearer: [] }]);
+});
+
+test("the document pins info + bearer scheme + run/error component schemas", () => {
+ const doc = buildOpenApi(toolsFrom(EXPORT_NARROWED), SERVER_INFO) as any;
+ assert.equal(doc.openapi, "3.1.0");
+ assert.deepEqual(doc.info, { title: "jaiph — tools.jh", version: "9.9.9" });
+ assert.deepEqual(doc.components.securitySchemes.bearer, { type: "http", scheme: "bearer" });
+ assert.ok(doc.components.schemas.Run, "Run schema present");
+ assert.ok(doc.components.schemas.Error, "Error schema present");
+ // Static run-resource paths are present.
+ for (const p of ["/v1/workflows", "/v1/runs", "/v1/runs/{id}", "/v1/runs/{id}/cancel", "/healthz"]) {
+ assert.ok(doc.paths[p], `path ${p} present`);
+ }
+});
+
+test("buildOpenApi validates for a multi-tool (no-export) module too", async () => {
+ const tools = toolsFrom(
+ ["# Build.", "workflow build(target) {", ' return "${target}"', "}", "", "# Deploy.", "workflow deploy() {", ' return "ok"', "}", ""].join("\n"),
+ );
+ assert.deepEqual(tools.map((t) => t.name).sort(), ["build", "deploy"]);
+ const doc = buildOpenApi(tools, SERVER_INFO);
+ const result = await new Validator().validate(doc);
+ assert.equal(result.valid, true, `validation failed: ${JSON.stringify(result.errors)}`);
+});
diff --git a/src/cli/serve/openapi.ts b/src/cli/serve/openapi.ts
new file mode 100644
index 00000000..b5948e99
--- /dev/null
+++ b/src/cli/serve/openapi.ts
@@ -0,0 +1,231 @@
+import type { McpToolSpec } from "../mcp/tools";
+
+/** Identity of the server, embedded in `info`. */
+export interface OpenApiServerInfo {
+ /** `info.title` — e.g. `jaiph — tools.jh`. */
+ title: string;
+ /** `info.version` — the jaiph `VERSION`. */
+ version: string;
+}
+
+/** A bearer-secured operation on the `/v1/*` surface. */
+const BEARER_SECURITY = [{ bearer: [] as string[] }];
+
+/** Standard `{error:{code,message}}` responses, keyed by HTTP status. */
+function errorResponse(description: string): Record {
+ return {
+ description,
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
+ };
+}
+
+function runResponse(description: string): Record {
+ return {
+ description,
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Run" } } },
+ };
+}
+
+/**
+ * Build the OpenAPI 3.1.0 document for a running `jaiph serve` instance. Pure:
+ * the same `(tools, serverInfo)` always yields the same document, so it can be
+ * regenerated per request and picks up hot-reloaded tool sets for free.
+ *
+ * One concrete path per workflow (`/v1/workflows//runs`) carries that
+ * workflow's own `operationId`, `#`-comment description, and the exact
+ * MCP-derived input schema as its JSON request body — which is what makes
+ * Swagger UI render a usable per-workflow form. The static run-resource paths,
+ * the run/error component schemas, and the bearer security scheme complete the
+ * document.
+ */
+export function buildOpenApi(tools: McpToolSpec[], serverInfo: OpenApiServerInfo): Record {
+ const paths: Record = {};
+
+ for (const tool of tools) {
+ const requestBody =
+ tool.params.length > 0
+ ? {
+ required: true,
+ content: { "application/json": { schema: tool.inputSchema } },
+ }
+ : { required: false, content: { "application/json": { schema: tool.inputSchema } } };
+ paths[`/v1/workflows/${tool.name}/runs`] = {
+ post: {
+ operationId: `run_${tool.name}`,
+ summary: `Run the ${tool.name} workflow`,
+ description: tool.description,
+ security: BEARER_SECURITY,
+ parameters: [
+ {
+ name: "wait",
+ in: "query",
+ required: false,
+ description: "Respond only when the run is terminal (200) instead of returning 202 immediately.",
+ schema: { type: "boolean" },
+ },
+ ],
+ requestBody,
+ responses: {
+ "200": runResponse("Run reached a terminal state (wait=true)."),
+ "202": runResponse("Run accepted and started."),
+ "400": errorResponse("Invalid arguments."),
+ "401": errorResponse("Missing or invalid bearer token."),
+ "413": errorResponse("Request body too large."),
+ "415": errorResponse("Request body was not application/json."),
+ "429": errorResponse("Too many concurrent runs."),
+ },
+ },
+ };
+ }
+
+ paths["/healthz"] = {
+ get: {
+ operationId: "healthz",
+ summary: "Liveness/readiness probe",
+ responses: {
+ "200": {
+ description: "Server is up.",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ status: { type: "string" },
+ version: { type: "string" },
+ tools: { type: "integer" },
+ in_flight: { type: "integer" },
+ },
+ required: ["status", "version", "tools", "in_flight"],
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ paths["/v1/workflows"] = {
+ get: {
+ operationId: "listWorkflows",
+ summary: "List exposed workflows",
+ security: BEARER_SECURITY,
+ responses: {
+ "200": {
+ description: "The exposed workflows.",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ workflows: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ description: { type: "string" },
+ params: { type: "array", items: { type: "string" } },
+ },
+ required: ["name", "description", "params"],
+ },
+ },
+ },
+ required: ["workflows"],
+ },
+ },
+ },
+ },
+ "401": errorResponse("Missing or invalid bearer token."),
+ },
+ },
+ };
+
+ paths["/v1/runs"] = {
+ get: {
+ operationId: "listRuns",
+ summary: "List runs started by this server (newest first)",
+ security: BEARER_SECURITY,
+ responses: {
+ "200": {
+ description: "Runs started by this server process.",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: { runs: { type: "array", items: { $ref: "#/components/schemas/Run" } } },
+ required: ["runs"],
+ },
+ },
+ },
+ },
+ "401": errorResponse("Missing or invalid bearer token."),
+ },
+ },
+ };
+
+ paths["/v1/runs/{id}"] = {
+ get: {
+ operationId: "getRun",
+ summary: "Fetch one run",
+ security: BEARER_SECURITY,
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
+ responses: {
+ "200": runResponse("The run."),
+ "401": errorResponse("Missing or invalid bearer token."),
+ "404": errorResponse("Unknown run id."),
+ },
+ },
+ };
+
+ paths["/v1/runs/{id}/cancel"] = {
+ post: {
+ operationId: "cancelRun",
+ summary: "Cancel an in-flight run",
+ security: BEARER_SECURITY,
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
+ responses: {
+ "202": runResponse("Cancellation requested."),
+ "401": errorResponse("Missing or invalid bearer token."),
+ "404": errorResponse("Unknown run id."),
+ "409": errorResponse("Run already terminal."),
+ },
+ },
+ };
+
+ return {
+ openapi: "3.1.0",
+ info: { title: serverInfo.title, version: serverInfo.version },
+ paths,
+ components: {
+ securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
+ schemas: {
+ Run: {
+ type: "object",
+ properties: {
+ run_id: { type: "string" },
+ workflow: { type: "string" },
+ status: { type: "string", enum: ["running", "succeeded", "failed", "cancelled"] },
+ started_at: { type: "string" },
+ ended_at: { type: ["string", "null"] },
+ exit_status: { type: ["integer", "null"] },
+ signal: { type: ["string", "null"] },
+ result_text: { type: ["string", "null"] },
+ run_dir: { type: ["string", "null"] },
+ },
+ required: ["run_id", "workflow", "status", "started_at"],
+ },
+ Error: {
+ type: "object",
+ properties: {
+ error: {
+ type: "object",
+ properties: { code: { type: "string" }, message: { type: "string" } },
+ required: ["code", "message"],
+ },
+ },
+ required: ["error"],
+ },
+ },
+ },
+ };
+}
diff --git a/src/cli/serve/server.ts b/src/cli/serve/server.ts
new file mode 100644
index 00000000..fda1efd5
--- /dev/null
+++ b/src/cli/serve/server.ts
@@ -0,0 +1,77 @@
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import type { AddressInfo } from "node:net";
+import { MAX_BODY_BYTES, type ServeHandler, type ServeRequest } from "./handler";
+
+/**
+ * Wire a `node:http` server to a `ServeHandler`. This is the only place that
+ * touches sockets: it reads the request body (aborting past `MAX_BODY_BYTES`
+ * so a hostile client can't exhaust memory — the handler turns the flag into a
+ * 413), normalizes the request, and streams the handler's response back. All
+ * routing/auth/execution decisions live in the handler.
+ */
+export function createHttpServer(handler: ServeHandler, log: (line: string) => void): Server {
+ return createServer((req: IncomingMessage, res: ServerResponse) => {
+ readBody(req)
+ .then(({ body, tooLarge }) => {
+ const url = new URL(req.url ?? "/", "http://localhost");
+ const serveReq: ServeRequest = {
+ method: req.method ?? "GET",
+ path: url.pathname,
+ query: url.searchParams,
+ headers: req.headers as Record,
+ body,
+ bodyTooLarge: tooLarge,
+ };
+ return handler.handleRequest(serveReq);
+ })
+ .then((response) => {
+ res.writeHead(response.status, response.headers);
+ res.end(response.body);
+ })
+ .catch((err) => {
+ log(`jaiph serve: request handling failed: ${err instanceof Error ? err.message : String(err)}`);
+ if (!res.headersSent) {
+ res.writeHead(500, { "content-type": "application/json" });
+ }
+ res.end(JSON.stringify({ error: { code: "E_INTERNAL", message: "internal error" } }));
+ });
+ });
+}
+
+/** Read the request body as a string, flagging (and truncating) once it passes the cap. */
+function readBody(req: IncomingMessage): Promise<{ body: string; tooLarge: boolean }> {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let total = 0;
+ let tooLarge = false;
+ req.on("data", (chunk: Buffer) => {
+ total += chunk.length;
+ if (total > MAX_BODY_BYTES) {
+ // Past the cap: stop buffering (bound memory) but keep draining so the
+ // socket can carry the 413 response.
+ tooLarge = true;
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => resolve({ body: tooLarge ? "" : Buffer.concat(chunks).toString("utf8"), tooLarge }));
+ req.on("error", reject);
+ });
+}
+
+/** Begin listening; resolves with the actual bound port (handles port 0). */
+export function listen(server: Server, host: string, port: number): Promise {
+ return new Promise((resolve, reject) => {
+ const onError = (err: Error): void => {
+ server.removeListener("listening", onListening);
+ reject(err);
+ };
+ const onListening = (): void => {
+ server.removeListener("error", onError);
+ resolve((server.address() as AddressInfo).port);
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(port, host);
+ });
+}
diff --git a/src/cli/shared/generation.ts b/src/cli/shared/generation.ts
new file mode 100644
index 00000000..6e35fbac
--- /dev/null
+++ b/src/cli/shared/generation.ts
@@ -0,0 +1,136 @@
+import { mkdirSync, unwatchFile, watchFile } from "node:fs";
+import { join } from "node:path";
+import { loadModuleGraph, writeModuleGraph, type ModuleGraph } from "../../transpile/module-graph";
+import { collectDiagnostics } from "../../transpile/validate";
+import { buildScriptsFromGraph } from "../../transpiler";
+import { resolveModuleMetadata, metadataToConfig } from "../../config";
+import {
+ resolveDockerConfig,
+ checkDockerAvailable,
+ prepareImage,
+ selectMcpSandboxMode,
+ type DockerRunConfig,
+ type SandboxMode,
+} from "../../runtime/docker";
+import { resolveRuntimeEnv } from "../run/env";
+import { preflightAgentCredentials } from "../run/preflight-credentials";
+import { deriveTools, type McpToolSpec } from "../mcp/tools";
+import type { WorkflowCallEnvironment } from "../exec/call";
+
+/** How often `watchFile` polls module sources for hot reload (ms). */
+export const WATCH_INTERVAL_MS = 750;
+
+/** Everything one generation of a workflow server needs to serve + call. */
+export interface GenerationState {
+ graph: ModuleGraph;
+ tools: McpToolSpec[];
+ callEnv: WorkflowCallEnvironment;
+}
+
+/**
+ * Load (or reload) everything one generation of a workflow server needs:
+ * module graph, compile-time validation, tool derivation, emitted scripts, and
+ * the serialized graph the spawned runners consume. Shared by `jaiph mcp` and
+ * `jaiph serve`. Throws on parse/loader errors; returns diagnostics without
+ * throwing on validation errors.
+ */
+export function loadGeneration(
+ inputAbs: string,
+ workspaceRoot: string,
+ tempRoot: string,
+ generation: number,
+ extraEnv: Record,
+ log: (line: string) => void,
+ label: string,
+): { state?: GenerationState; failures: string[] } {
+ const graph = loadModuleGraph(inputAbs, workspaceRoot);
+ const diag = collectDiagnostics(graph);
+ if (diag.errors.length > 0) {
+ return {
+ failures: diag.sorted().map((d) => `${d.file}:${d.line}:${d.col} ${d.code} ${d.message}`),
+ };
+ }
+
+ const mod = graph.modules.get(inputAbs)!.ast;
+ const { tools, warnings } = deriveTools(mod, inputAbs);
+ for (const w of warnings) log(`${label}: ${w}`);
+
+ const outDir = join(tempRoot, `gen-${generation}`);
+ mkdirSync(outDir, { recursive: true });
+ const { scriptsDir } = buildScriptsFromGraph(graph, outDir);
+ const graphFile = join(outDir, ".jaiph-module-graph.json");
+ writeModuleGraph(graphFile, graph);
+
+ const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
+ const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
+
+ return {
+ state: {
+ graph,
+ tools,
+ callEnv: { inputAbs, workspaceRoot, mod, effectiveConfig, scriptsDir, graphFile, outDir, extraEnv },
+ },
+ failures: [],
+ };
+}
+
+/**
+ * Resolve the shared startup sandbox posture for a workflow server (`jaiph mcp`
+ * and `jaiph serve`): the env-driven Docker selection (`jaiph run` semantics),
+ * a one-time image preparation when Docker is on, and the credential pre-flight
+ * (demoted to warnings — the server may outlive a credential fix). Throws when
+ * Docker is enabled but unavailable / the image can't be prepared; the caller
+ * turns that into an exit-1. Returns the resolved config + sandbox mode so the
+ * caller can print its own startup notice.
+ */
+export function resolveStartupPosture(
+ state: GenerationState,
+ inputAbs: string,
+ workspaceRoot: string,
+ log: (line: string) => void,
+): { dockerConfig: DockerRunConfig; sandboxMode: SandboxMode } {
+ const mod = state.graph.modules.get(inputAbs)!.ast;
+ const startupEnv = resolveRuntimeEnv(state.callEnv.effectiveConfig, workspaceRoot, inputAbs);
+ const dockerConfig = resolveDockerConfig(resolveModuleMetadata(mod, process.env)?.runtime, startupEnv);
+ if (dockerConfig.enabled) {
+ // Prepare the image once here rather than per call (a cold pull is slow).
+ checkDockerAvailable();
+ prepareImage(dockerConfig);
+ }
+ const sandboxMode = selectMcpSandboxMode(startupEnv);
+ // Credential pre-flight once at startup (warnings only: the server may outlive
+ // a credential fix, and per-call failures still surface).
+ const credPreflight = preflightAgentCredentials({
+ mod,
+ inputAbs,
+ runtimeEnv: startupEnv,
+ dockerEnabled: dockerConfig.enabled,
+ });
+ for (const w of [...credPreflight.warnings, ...credPreflight.errors]) log(w);
+ return { dockerConfig, sandboxMode };
+}
+
+/**
+ * A `watchFile`-based source watcher shared by `jaiph mcp` and `jaiph serve`.
+ * `rewatch(files)` swaps the watched set (unwatch old, watch new) so each hot
+ * reload re-derives the file list from the current module graph; `stop()`
+ * unwatches everything on shutdown. The `onChange` callback is the single
+ * listener registered against every file, so unwatch matches watch exactly.
+ */
+export function createSourceWatcher(
+ intervalMs: number,
+ onChange: () => void,
+): { rewatch: (files: string[]) => void; stop: () => void } {
+ let watched: string[] = [];
+ return {
+ rewatch(files: string[]): void {
+ for (const f of watched) unwatchFile(f, onChange);
+ watched = [...files];
+ for (const f of watched) watchFile(f, { interval: intervalMs }, onChange);
+ },
+ stop(): void {
+ for (const f of watched) unwatchFile(f, onChange);
+ watched = [];
+ },
+ };
+}
diff --git a/src/cli/shared/usage.ts b/src/cli/shared/usage.ts
index 5e02f1d0..710ff6cb 100644
--- a/src/cli/shared/usage.ts
+++ b/src/cli/shared/usage.ts
@@ -15,6 +15,7 @@ export function printUsage(): void {
" jaiph format [--check] [--indent ] ",
" jaiph compile [--json] [--workspace ] ...",
" jaiph mcp [--workspace ] [--env KEY[=VALUE]]... # serve the file's workflows as MCP tools over stdio (alias: jaiph --mcp)",
+ " jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... # serve workflows as an HTTP API + OpenAPI + Swagger UI",
"",
"Global options:",
" -h, --help show this usage (jaiph --help) — each subcommand also accepts -h / --help",
@@ -63,6 +64,17 @@ export function printUsage(): void {
" --workspace workspace root for import resolution (default: auto-detect).",
" --env KEY=VALUE define KEY in every tool call's env (repeatable); --env KEY forwards the host value.",
"",
+ "jaiph serve:",
+ " Serve the file's workflows as an HTTP API with a generated OpenAPI 3.1 document",
+ " and Swagger UI at /docs. Exposure mirrors `jaiph mcp`. Runs are durable resources",
+ " under .jaiph/runs/, inspectable via GET /v1/runs/{id}. Set JAIPH_SERVE_TOKEN to",
+ " require a bearer token on /v1/*; binding a non-loopback --host without it is a",
+ " startup error. JAIPH_SERVE_MAX_CONCURRENT (default 4) caps simultaneous runs.",
+ " --host listen address (default: 127.0.0.1)",
+ " --port listen port (default: 5247)",
+ " --workspace workspace root for import resolution (default: auto-detect).",
+ " --env KEY=VALUE define KEY in every run's env (repeatable); --env KEY forwards the host value.",
+ "",
"Examples:",
" jaiph --help",
" jaiph --version",
@@ -90,6 +102,8 @@ export function printUsage(): void {
" jaiph compile --json .",
" jaiph mcp ./tools.jh",
" jaiph mcp --env GITHUB_TOKEN ./tools.jh",
+ " jaiph serve ./tools.jh",
+ " JAIPH_SERVE_TOKEN=secret jaiph serve --host 0.0.0.0 --port 8080 ./tools.jh",
"",
].join("\n"),
);
@@ -154,6 +168,10 @@ export interface ParsedArgs {
inplace?: boolean;
unsafe?: boolean;
yes?: boolean;
+ /** `jaiph serve` listen host. */
+ host?: string;
+ /** `jaiph serve` listen port (kept as raw string; the command validates it). */
+ port?: string;
/** Repeatable `--env` passthrough entries, in flag order. */
env: EnvSpec[];
positional: string[];
@@ -166,6 +184,8 @@ export function parseArgs(args: string[]): ParsedArgs {
let inplace: boolean | undefined;
let unsafe: boolean | undefined;
let yes: boolean | undefined;
+ let host: string | undefined;
+ let port: string | undefined;
const env: EnvSpec[] = [];
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
@@ -202,6 +222,23 @@ export function parseArgs(args: string[]): ParsedArgs {
continue;
}
+ // `jaiph serve` listen address flags.
+ if (name === "--host" || name === "--port") {
+ let val: string | undefined;
+ if (inlineValue !== undefined) {
+ val = inlineValue;
+ } else {
+ val = args[i + 1];
+ i += 1;
+ }
+ if (!val) {
+ throw new Error(`${name} requires a value`);
+ }
+ if (name === "--host") host = val;
+ else port = val;
+ continue;
+ }
+
// Repeatable `--env KEY` / `--env KEY=VALUE`. Value comes from `=` or the
// next token; validation (name shape, reserved keys) happens now, but a
// bare `KEY`'s host lookup is deferred to spawn time (resolveEnvPairs).
@@ -234,5 +271,5 @@ export function parseArgs(args: string[]): ParsedArgs {
positional.push(arg);
}
- return { target, raw, workspace, inplace, unsafe, yes, env, positional };
+ return { target, raw, workspace, inplace, unsafe, yes, host, port, env, positional };
}
From e4e38748ed3ebfd94fbf706ac4cfc3c8dd0f5990 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 08:41:14 +0200
Subject: [PATCH 02/24] Feat: stream serve run events and expose run artifacts
Add run-inspection endpoints to `jaiph serve` so clients can watch a run
in flight and retrieve what it produced, closing the API half of the
"inspect what it is doing" feedback.
GET /v1/runs/{id}/events serves the run's hash-chained, credential-redacted
run_summary.jsonl verbatim: NDJSON by default, or SSE (Accept:
text/event-stream) that replays existing journal lines, follows the file
via ~250ms polling for appended lines, sends :ka keep-alives every 15s,
and emits `event: end` once the registry marks the run terminal (works for
already-terminal runs too). GET /v1/runs/{id}/artifacts lists files under
the run dir's artifacts/, and GET /v1/runs/{id}/artifacts/{path} downloads
one as application/octet-stream. Downloads are traversal-proof: the request
is resolved and realpath-contained against the artifacts dir, rejecting
`..`, absolute paths, and symlinks escaping it with 404. Raw %06d-*.out/.err
capture files are never exposed by any route. Docker-mode runs read the
host-side run dir via the existing discoverDockerRunDir/remapContainerPath
layer. Docs and integration/e2e coverage (SSE mid-run, redaction, artifact
round-trip, traversal battery) included.
---
CHANGELOG.md | 2 +
QUEUE.md | 29 -----
docs/cli.md | 3 +
docs/serve.md | 35 ++++-
e2e/tests/147_serve_http_api.sh | 45 ++++++-
integration/serve-server.test.ts | 187 ++++++++++++++++++++++++++-
src/cli/commands/serve.ts | 10 +-
src/cli/serve/handler.test.ts | 120 ++++++++++++++++++
src/cli/serve/handler.ts | 134 ++++++++++++++++++++
src/cli/serve/openapi.ts | 83 ++++++++++++
src/cli/serve/runfiles.test.ts | 211 +++++++++++++++++++++++++++++++
src/cli/serve/runfiles.ts | 183 +++++++++++++++++++++++++++
src/cli/serve/server.ts | 42 +++++-
src/cli/shared/errors.ts | 36 ++++--
src/cli/shared/generation.ts | 20 ++-
src/cli/shared/usage.ts | 4 +-
16 files changed, 1094 insertions(+), 50 deletions(-)
create mode 100644 src/cli/serve/runfiles.test.ts
create mode 100644 src/cli/serve/runfiles.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9fd16e3..34b840b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
## All changes
+- **Feat — `jaiph serve` run inspection: live event stream + artifact download:** A `jaiph serve` client can now watch what a run is doing while it executes and retrieve the files it publishes, not just read its terminal result. Three new bearer-gated endpoints (`src/cli/serve/runfiles.ts` + routes in `src/cli/serve/handler.ts`, documented in the generated OpenAPI via `src/cli/serve/openapi.ts`) read from the run's durable, host-visible run directory — the same `run_summary.jsonl` journal and `artifacts/` tree `jaiph run` writes. **`GET /v1/runs/{id}/events`** serves the run's `run_summary.jsonl`: by default the whole journal as `application/x-ndjson`, verbatim, then closes; with `Accept: text/event-stream` it switches to **Server-Sent Events** — `streamRunEventsSse` replays every existing line as `data: `, then follows the file as it appends (polling ~250 ms), emits a `:ka` keep-alive comment every 15 s, and closes with `event: end` once the server's run registry marks the run terminal (so it works identically for a still-running run and an already-terminal one: full replay plus an immediate `end`). **`GET /v1/runs/{id}/artifacts`** returns `{artifacts: [{path, size, mtime}, …]}` for every regular file under the run's `artifacts/` (recursive, `[]` when none). **`GET /v1/runs/{id}/artifacts/{path}`** downloads one file as `application/octet-stream` with a `Content-Disposition` filename. All three are `404` on an unknown run id and `401` unauthenticated. To reach a run that is *still executing* — whose dir is only recorded on its record at finalize — the server injects a `resolveRunDir` resolver (`src/cli/commands/serve.ts`) that falls back to scanning the host runs root for the run id via `findRunDir` (`src/cli/shared/errors.ts`), which also resolves the **Docker-mode** host-side run dir (the run dir is a host mount in every sandbox mode). The HTTP layer grew a streaming path: `ServeResponse` now carries an optional `bodyBuffer` (binary bodies — NDJSON journal, artifact bytes) and a `stream(target)` hook driven by `src/cli/serve/server.ts`'s `makeStreamTarget`, which wires SSE writes to the socket and flips `aborted` (stopping the follow loop) the moment the client disconnects. **Security:** the journal is served **verbatim** — the credential redaction `RuntimeEventEmitter` already applies (values of `*_API_KEY` / `*_TOKEN` / `*_SECRET` env vars → `[REDACTED]`) *is* the redaction guarantee — and the raw per-step `%06d-*.out` / `.err` capture files are unreachable **by construction**: `listArtifacts` walks only the `artifacts/` subtree (the captures live in the run-dir root), and downloads go through `resolveArtifactPath`, which is traversal-proof via three guards checked before any read — reject empty/NUL requests, lexical containment (`resolve()` collapses `..`/absolute paths and the result must sit under `artifacts/`), and **symlink** containment (`realpathSync` on both the artifacts dir and the candidate — an `artifacts/` symlink pointing outside is rejected without reading its target, while one pointing inside still serves). Tests: `src/cli/serve/runfiles.test.ts` (artifact listing skips `.out`/`.err` captures; the full traversal battery — `../`, absolute, empty/NUL, directory, escaping symlink rejected without reading the target, inside-pointing symlink served, and a capture-file symlink rejected; SSE replays a terminal journal as `data:` frames then `event: end`, and stops promptly on client disconnect), `integration/serve-server.test.ts` (SSE mid-run: replayed `WORKFLOW_START`, a `STEP_END` **before** the run is terminal, `event: end` + socket close after completion, and the concatenated `data:` payloads equal the final journal line set; NDJSON on a terminal run byte-matches the journal; unknown run → `404`; unauthenticated → `401`; a credential echoed by a run is absent and `[REDACTED]` present in the stream; artifacts list-then-download round-trips byte-identically and a traversal is `404`), and `e2e/tests/147_serve_http_api.sh` (NDJSON byte-matches `run_summary.jsonl`; artifact list + byte-identical download; URL-encoded `%2e%2e` traversal → `404`). Docs: "Watch a run as it executes" and "Download a run's artifacts" sections in [Serve workflows over HTTP](serve.md) (with the raw-captures non-exposure called out as a security property), and the three endpoint rows in the `jaiph serve` table in [CLI](cli.md). (Deployability feedback `2026-07-23` — "a UI on top to be able to inspect what it is doing"; contract in `design/2026-07-23-serve-http-api.md` § Events streaming.)
+
- **Feat — `jaiph serve`: HTTP API with OpenAPI 3.1 + Swagger UI:** A new command turns a `.jh` file into a network-reachable, self-describing HTTP service, complementing `jaiph mcp` (which exposes the same workflows only over stdio JSON-RPC to a co-located parent). `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... ` (default `127.0.0.1:5247`) is dispatched from `src/cli/index.ts` and implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`, hand-rolled on `node:http` with **no runtime npm dependencies** (project policy — `package.json` `dependencies` stays absent; the MCP server set the precedent). Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (errors to stderr, exit `1`), `--env` resolved once via `resolveEnvPairs`, Docker config + image prepared once, credential pre-flight as warnings, and a sandbox-mode notice; all logs go to stderr and one startup line prints the listen URL and the `/docs` URL. Endpoints: `GET /` → `302 /docs`; unauthenticated `GET /healthz`, `GET /openapi.json`, `GET /docs`; and bearer-gated `GET /v1/workflows`, `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → terminal `200`), `GET /v1/runs`, `GET /v1/runs/{id}`, `POST /v1/runs/{id}/cancel`. A run is a durable resource `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` with `status ∈ running|succeeded|failed|cancelled`; **a workflow failure is not an HTTP error** — it comes back `200`/`202` with `status:"failed"` and the same failure narrative `jaiph mcp` returns. Errors use `{error:{code,message}}`: `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415 E_UNSUPPORTED_MEDIA_TYPE` (non-`application/json` body), `429 E_TOO_MANY_RUNS`. Workflow exposure, naming, descriptions, and request-body schemas come from the shared `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties:false`), with param validation mirroring MCP's `-32602` rules as HTTP `400`. **Auth:** bearer token from `JAIPH_SERVE_TOKEN` (env only, never argv), constant-time compared, required on all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only, a documented trade-off) — and binding a non-loopback `--host` without the token set is a startup error before any socket opens. **Limits:** `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs → `429`. `GET /openapi.json` returns OpenAPI **3.1.0** generated per request by the pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`) — one concrete path per workflow (own `operationId`, `#`-comment description, MCP input schema as request body), the run-resource paths, run/error component schemas, and a bearer `securityScheme`. `GET /docs` serves a static Swagger UI shell (`src/cli/serve/docs.ts`) loading `swagger-ui-dist` from a CDN with a **pinned exact version + SRI `integrity` hashes + `crossorigin`** on both assets and `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})` — no vendored assets, so `/docs` needs browser internet access and `/openapi.json` is the offline fallback (air-gap consequence recorded in the design doc). Execution reuses the MCP call layer, **moved** from `src/cli/mcp/call.ts` to `src/cli/exec/call.ts` (`McpCallResult` → `WorkflowCallResult`, caller now supplies `runId`, result extended with `{runDir?, exitStatus?, signal?}`); sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls. The generation/hot-reload machinery (`loadState`, watch/rewatch, generation dirs) was extracted from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts` and is now shared by both commands; editing a served source re-derives the workflow set and the OpenAPI document with no restart, and a superseded generation's scripts dir is deleted only after its in-flight runs finish (refcounted, since HTTP runs can outlive a reload). Shutdown: the first `SIGINT`/`SIGTERM` stops accepting and drains in-flight runs, a second signal cancels them, exit `0`. `jaiph mcp` behavior is unchanged after the extraction (its own tests prove it). Tests: `src/cli/serve/handler.test.ts` (injected-deps handler — unknown workflow `404`; missing / non-string / unexpected param `400`; cancel `202` → terminal `cancelled` with child + container teardown, cancel-on-terminal `409`; `429`/`413`/`415`; auth matrix incl. non-loopback-without-token exit `1`), `src/cli/serve/openapi.test.ts` (output passes a real OpenAPI 3.1 validator devDependency; one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture), `src/cli/serve/docs.test.ts` (pinned version + `integrity` + `crossorigin` on both assets), `integration/serve-server.test.ts` (real server on port 0 — `wait=true` round-trips a `return` value into `result_text` with `status:"succeeded"`; async `202` + `Location` polled to the same terminal result; failing workflow → HTTP `200` with `status:"failed"`, `exit_status`, and `run dir:` in `result_text`; hot-reload surfaces a new workflow in `/openapi.json` and `/v1/workflows` while a pre-reload run still completes), and `e2e/tests/147_serve_http_api.sh` (wired into `e2e/test_all.sh`). Docs: new [Serve workflows over HTTP](serve.md) how-to, a `jaiph serve` section in [CLI](cli.md), `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` rows in [Environment variables](env-vars.md), `printUsage` in `src/cli/shared/usage.ts`, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23`; contract in `design/2026-07-23-serve-http-api.md`.)
# 0.11.0
diff --git a/QUEUE.md b/QUEUE.md
index 5d29085f..92226661 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,35 +14,6 @@ Process rules:
***
-## Feat: `jaiph serve` run inspection — live event stream + artifacts #dev-ready
-
-**Source:** Deployability feedback (2026-07-23): "a UI on top to be able to inspect what it is doing" — the API half of that is streaming run events and exposing artifacts over HTTP. Contract: `design/2026-07-23-serve-http-api.md` (§ Events streaming). Requires the `jaiph serve` command (`src/cli/commands/serve.ts`, run registry + bearer auth) already in the codebase.
-
-**Problem:** A `jaiph serve` client can see a run's terminal result but not what the run is doing while it executes, and cannot retrieve published artifacts. The durable journal (`run_summary.jsonl`, written by `RuntimeEventEmitter` — hash-chained, credential-redacted, host-visible in every sandbox mode because the run dir is a host mount) already contains everything needed; it just isn't reachable over HTTP.
-
-**Required behavior:**
-
-* `GET /v1/runs/{id}/events` (bearer-authed, 404 unknown run):
- * default: `application/x-ndjson` — the run's `run_summary.jsonl` content as-is, then close.
- * `Accept: text/event-stream` → SSE: replay every existing journal line as `data: `, then follow the file (poll ~250 ms) emitting new lines as they append; when the server's registry marks the run terminal, emit `event: end` and close. Keep-alive comment (`:ka`) every 15 s. Works for already-terminal runs (full replay + immediate `end`).
- * The journal is served verbatim — the redaction already applied by `RuntimeEventEmitter` is the redaction guarantee. Raw `%06d-*.out/.err` capture files are **never** exposed by any endpoint.
-* `GET /v1/runs/{id}/artifacts` → JSON list of files under the run dir's `artifacts/` (relative paths, size, mtime); empty list when none.
-* `GET /v1/runs/{id}/artifacts/{path}` → file bytes, `application/octet-stream`, `Content-Disposition` filename. **Traversal-proof:** resolve the requested path against the artifacts dir and reject (404) anything escaping it — `..` segments, absolute paths, and symlinks pointing outside the artifacts dir (check `realpath` containment, not string prefix only).
-* Docker-mode runs: the journal/artifacts are read from the host-side run dir (discovered as the existing call layer already does via `discoverDockerRunDir`/`remapContainerPath` in `src/cli/shared/errors.ts`).
-* Docs: extend `docs/serve.md` with a "watch a run" section (curl SSE example) and artifacts section; note the raw-captures non-exposure as a security property.
-
-Acceptance:
-
-* Integration test with a multi-step fixture workflow slow enough to observe (e.g. `sleep` steps): connect SSE mid-run and assert (a) replayed `WORKFLOW_START` arrives, (b) at least one `STEP_END` event arrives **before** the run is terminal, (c) `event: end` arrives and the socket closes after completion, (d) the concatenated `data:` payloads equal the final `run_summary.jsonl` line set.
-* Test: NDJSON mode on a terminal run byte-matches the journal file; events endpoint on an unknown run id → 404; unauthenticated → 401.
-* Redaction test: a workflow that echoes a credential env value (key matching the `_API_KEY`/`_TOKEN` redaction suffixes, value ≥8 chars) produces an event stream where the value is absent and `[REDACTED]` present.
-* Artifacts round-trip test: a workflow publishing a file to `$JAIPH_ARTIFACTS_DIR` lists and downloads it byte-identically.
-* Traversal test battery: `../`-containing path, absolute path, URL-encoded `%2e%2e`, and a symlink inside `artifacts/` pointing outside the run dir all return 404 without reading the target; a symlink target **inside** artifacts still serves.
-* A grep-style test or unit assertion proves no route serves `*.out`/`*.err` capture files.
-* `npm test` passes.
-
-***
-
## Feat: OTLP trace export — one span tree per run, zero dependencies #dev-ready
**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable would be the easiest way" to make jaiph observable in a company setting. This task is the OTEL half.
diff --git a/docs/cli.md b/docs/cli.md
index f6ce3412..a73902ec 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -377,6 +377,9 @@ Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to s
| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. |
| `GET /v1/runs` | bearer | Runs started by this process (in-memory), newest first. |
| `GET /v1/runs/{id}` | bearer | The run object. `404` unknown. |
+| `GET /v1/runs/{id}/events` | bearer | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot; `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
+| `GET /v1/runs/{id}/artifacts` | bearer | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. |
+| `GET /v1/runs/{id}/artifacts/{path}` | bearer | Download one published file (`application/octet-stream`). Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. |
| `POST /v1/runs/{id}/cancel` | bearer | `202`; the run reaches `cancelled`. `409` if already terminal. |
The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled`. **A workflow failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415` (non-`application/json` body), and `429 E_TOO_MANY_RUNS`.
diff --git a/docs/serve.md b/docs/serve.md
index eda48df7..23bef68b 100644
--- a/docs/serve.md
+++ b/docs/serve.md
@@ -50,11 +50,42 @@ curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first), and stop one with `POST /v1/runs/{id}/cancel`.
-## 4. Use the Swagger UI
+## 4. Watch a run as it executes
+
+`GET /v1/runs/{id}/events` streams the run's durable event journal (`run_summary.jsonl`) — the same timeline the CLI progress tree is built from. Two modes:
+
+```bash
+# Snapshot (default): the whole journal as newline-delimited JSON, then close.
+curl -s http://127.0.0.1:5247/v1/runs/$ID/events
+
+# Live: Server-Sent Events — replays the journal so far, then follows it as the
+# run appends, and closes with an `event: end` when the run is terminal.
+curl -sN -H 'accept: text/event-stream' http://127.0.0.1:5247/v1/runs/$ID/events
+```
+
+Each SSE message is a `data:` line carrying one raw journal line (`WORKFLOW_START`, `STEP_START`/`STEP_END`, `LOG*`, `PROMPT_*`, `WORKFLOW_END`); a `:ka` comment every 15 s keeps proxies from idling the connection out. Connect while the run is still going to watch step-by-step, or after it finishes for a full replay plus an immediate `event: end`. (Add `-H 'authorization: Bearer '` when a token is set — `curl -N` disables buffering so events surface as they arrive.)
+
+> **Security:** the journal is served **verbatim** — the credential redaction `jaiph` applies when writing it (values of `*_API_KEY` / `*_TOKEN` / `*_SECRET` env vars become `[REDACTED]`) is the redaction guarantee. The raw per-step capture files (`NNNNNN-*.out` / `.err`) are **never** exposed by any endpoint; only the redacted journal and files a workflow explicitly publishes are reachable over HTTP.
+
+## 5. Download a run's artifacts
+
+Files a workflow publishes to `$JAIPH_ARTIFACTS_DIR` (see [artifacts](/how-to/artifacts)) are listable and downloadable:
+
+```bash
+# List published files: [{path, size, mtime}, ...] (empty when the run made none).
+curl -s http://127.0.0.1:5247/v1/runs/$ID/artifacts | jq
+
+# Download one by its relative path (application/octet-stream).
+curl -s http://127.0.0.1:5247/v1/runs/$ID/artifacts/report.txt -o report.txt
+```
+
+The download path is resolved strictly inside the run's `artifacts/` directory and is traversal-proof: `..` segments, absolute paths, and symlinks pointing outside the directory all return `404` without touching the target file.
+
+## 6. Use the Swagger UI
Open `http://127.0.0.1:5247/docs` in a browser to get a live form for every workflow. When a token is configured, paste it into the **Authorize** box (Swagger UI keeps it across reloads). The UI loads its assets from a pinned, SRI-verified CDN, so `/docs` needs internet access in the browser; air-gapped operators use `/openapi.json` with any locally-hosted renderer.
-## 5. Require a token and expose the port
+## 7. Require a token and expose the port
The token comes from the environment (never argv, which leaks into process listings). With it set, every `/v1/*` request must carry `Authorization: Bearer `; `/healthz`, `/openapi.json`, and `/docs` stay open.
diff --git a/e2e/tests/147_serve_http_api.sh b/e2e/tests/147_serve_http_api.sh
index 51541bca..cdd90641 100755
--- a/e2e/tests/147_serve_http_api.sh
+++ b/e2e/tests/147_serve_http_api.sh
@@ -43,6 +43,13 @@ workflow greet(name) {
workflow boom() {
fail "boom-failed"
}
+
+script publish = `printf 'artifact-payload' > "$JAIPH_ARTIFACTS_DIR/result.txt"`
+# Publishes a file into the run's artifacts dir.
+workflow make_artifact() {
+ run publish()
+ return "published"
+}
EOF
e2e::section "jaiph serve exposes an HTTP API over host-mode runs"
@@ -116,10 +123,44 @@ e2e::assert_equals "${boom_code}" "200" "a failing workflow is HTTP 200 (workflo
boom_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "${boom_body}")"
e2e::assert_equals "${boom_status}" "failed" "boom run status is failed"
-# --- GET /v1/workflows lists both exposed workflows ---
+# --- GET /v1/workflows lists all exposed workflows ---
workflows="$(curl -s "${base}/v1/workflows" | python3 -c '
import json, sys
names = sorted(w["name"] for w in json.load(sys.stdin)["workflows"])
print(",".join(names))
')"
-e2e::assert_equals "${workflows}" "boom,greet" "GET /v1/workflows lists both workflows"
+e2e::assert_equals "${workflows}" "boom,greet,make_artifact" "GET /v1/workflows lists all workflows"
+
+# --- GET /v1/runs/{id}/events (NDJSON) mirrors the durable journal ---
+# The greet run above returned a run object; re-run it capturing the id, then
+# byte-compare the events endpoint against the on-disk run_summary.jsonl.
+run_json="$(curl -s -X POST "${base}/v1/workflows/greet/runs?wait=true" \
+ -H 'content-type: application/json' -d '{"name":"events"}')"
+run_id="$(printf '%s' "${run_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["run_id"])')"
+run_dir="$(printf '%s' "${run_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["run_dir"])')"
+
+events_body="${TEST_DIR}/events.ndjson"
+curl -s "${base}/v1/runs/${run_id}/events" -o "${events_body}"
+# Full-content equality: the NDJSON stream is the journal, verbatim.
+e2e::assert_equals "$(cat "${events_body}")" "$(cat "${run_dir}/run_summary.jsonl")" \
+ "GET events (NDJSON) byte-matches the run's run_summary.jsonl"
+
+# --- artifacts: list + byte-identical download, traversal is rejected ---
+art_json="$(curl -s -X POST "${base}/v1/workflows/make_artifact/runs?wait=true" \
+ -H 'content-type: application/json' -d '{}')"
+art_id="$(printf '%s' "${art_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["run_id"])')"
+
+art_list="$(curl -s "${base}/v1/runs/${art_id}/artifacts" | python3 -c '
+import json, sys
+print(",".join(a["path"] for a in json.load(sys.stdin)["artifacts"]))
+')"
+e2e::assert_equals "${art_list}" "result.txt" "GET artifacts lists the published file"
+
+art_file="${TEST_DIR}/downloaded.txt"
+curl -s "${base}/v1/runs/${art_id}/artifacts/result.txt" -o "${art_file}"
+e2e::assert_equals "$(cat "${art_file}")" "artifact-payload" "artifact downloads byte-identically"
+
+# A URL-encoded `..` traversal escaping artifacts/ is a 404 (no bytes served).
+trav_code="$(curl -s -o /dev/null -w '%{http_code}' \
+ "${base}/v1/runs/${art_id}/artifacts/%2e%2e%2frun_summary.jsonl")"
+e2e::assert_equals "${trav_code}" "404" "artifact path traversal is rejected with 404"
diff --git a/integration/serve-server.test.ts b/integration/serve-server.test.ts
index afbd609c..28b9b5b1 100644
--- a/integration/serve-server.test.ts
+++ b/integration/serve-server.test.ts
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
-import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -25,6 +25,29 @@ const BASE_FIXTURE = [
' return "woke"',
"}",
"",
+ "script step_one = `sleep 0.4; echo one`",
+ "script step_two = `sleep 0.4; echo two`",
+ "# Two slow steps so a STEP_END is observable before the run is terminal.",
+ "workflow watchable() {",
+ " run step_one()",
+ " run step_two()",
+ ' return "watched"',
+ "}",
+ "",
+ 'script leak = `echo "token is $LEAK_API_KEY"`',
+ "# Echoes a credential value to stdout to exercise journal redaction.",
+ "workflow leak_secret() {",
+ " run leak()",
+ ' return "done"',
+ "}",
+ "",
+ 'script publish = `printf \'artifact-payload\' > "$JAIPH_ARTIFACTS_DIR/result.txt"`',
+ "# Publishes a file into the run's artifacts dir.",
+ "workflow make_artifact() {",
+ " run publish()",
+ ' return "published"',
+ "}",
+ "",
].join("\n");
function serveEnv(runsRoot: string, extra?: Record): NodeJS.ProcessEnv {
@@ -247,6 +270,168 @@ test("jaiph serve: binding a non-loopback host without JAIPH_SERVE_TOKEN exits 1
}
});
+/**
+ * Extract the journal `data:` payloads (one per journal line) from an SSE
+ * buffer. The terminating `event: end\ndata: {}` frame is excluded — only the
+ * replayed/followed journal lines are returned.
+ */
+function sseDataLines(sse: string): string[] {
+ return sse
+ .split("event: end")[0]
+ .split("\n")
+ .filter((l) => l.startsWith("data: "))
+ .map((l) => l.slice("data: ".length));
+}
+
+test("jaiph serve: SSE events replay WORKFLOW_START, stream a STEP_END mid-run, then close on event: end", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-sse-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ // Start async, then connect the event stream while the run is still going.
+ const startRes = await fetch(`${srv.baseUrl}/v1/workflows/watchable/runs`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(startRes.status, 202);
+ const runId = (await startRes.json()).run_id;
+
+ const evRes = await fetch(`${srv.baseUrl}/v1/runs/${runId}/events`, { headers: { accept: "text/event-stream" } });
+ assert.equal(evRes.status, 200);
+ assert.match(evRes.headers.get("content-type") ?? "", /text\/event-stream/);
+
+ // Read the stream to completion (the server closes after `event: end`).
+ const reader = evRes.body!.getReader();
+ const decoder = new TextDecoder();
+ let sse = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ sse += decoder.decode(value, { stream: true });
+ if (sse.includes("event: end")) break;
+ }
+
+ const dataLines = sseDataLines(sse);
+ const events = dataLines.map((l) => JSON.parse(l));
+ // (a) the replayed WORKFLOW_START is present.
+ assert.ok(events.some((e) => e.type === "WORKFLOW_START"), "WORKFLOW_START replayed");
+ // (b) a STEP_END arrived, and it precedes the terminating `event: end`.
+ const stepEndIdx = sse.indexOf('"type":"STEP_END"');
+ const endIdx = sse.indexOf("event: end");
+ assert.ok(stepEndIdx !== -1, "at least one STEP_END streamed");
+ assert.ok(stepEndIdx < endIdx, "STEP_END arrived before the run went terminal");
+ // (c) the stream closed with event: end.
+ assert.match(sse, /event: end/);
+
+ // (d) the concatenated data payloads equal the final run_summary.jsonl line set.
+ const run = await pollRun(srv.baseUrl, runId);
+ assert.equal(run.status, "succeeded");
+ const journalLines = readFileSync(join(run.run_dir, "run_summary.jsonl"), "utf8").split("\n").filter(Boolean);
+ assert.deepEqual(dataLines, journalLines, "SSE data payloads match the journal exactly");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: NDJSON events on a terminal run byte-match the journal; unknown run → 404, unauthenticated → 401", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-ndjson-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ // A token so the 401 path is exercised.
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs"), { JAIPH_SERVE_TOKEN: "t0ken" }));
+ const auth = { authorization: "Bearer t0ken" };
+ try {
+ const runRes = await fetch(`${srv.baseUrl}/v1/workflows/greet/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json", ...auth },
+ body: JSON.stringify({ name: "nd" }),
+ });
+ const run = await runRes.json();
+ assert.equal(run.status, "succeeded");
+
+ const ev = await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/events`, { headers: auth });
+ assert.equal(ev.status, 200);
+ assert.match(ev.headers.get("content-type") ?? "", /application\/x-ndjson/);
+ const body = Buffer.from(await ev.arrayBuffer());
+ assert.deepEqual(body, readFileSync(join(run.run_dir, "run_summary.jsonl")), "NDJSON is byte-identical to the journal");
+
+ // Unknown run id → 404.
+ assert.equal((await fetch(`${srv.baseUrl}/v1/runs/does-not-exist/events`, { headers: auth })).status, 404);
+ // Unauthenticated → 401.
+ assert.equal((await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/events`)).status, 401);
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: a credential echoed by a run is [REDACTED] in the event stream", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-redact-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const secret = "supersecretvalue123";
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")), ["--env", `LEAK_API_KEY=${secret}`]);
+ try {
+ const runRes = await fetch(`${srv.baseUrl}/v1/workflows/leak_secret/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ const run = await runRes.json();
+ assert.equal(run.status, "succeeded", `run failed: ${JSON.stringify(run)}`);
+
+ const ev = await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/events`);
+ const journal = await ev.text();
+ assert.ok(!journal.includes(secret), "the raw credential value must not appear in the event stream");
+ assert.ok(journal.includes("[REDACTED]"), "the redaction marker is present where the value was");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: artifacts round-trip — list then byte-identical download, traversal is 404", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-art-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ const runRes = await fetch(`${srv.baseUrl}/v1/workflows/make_artifact/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ const run = await runRes.json();
+ assert.equal(run.status, "succeeded", `run failed: ${JSON.stringify(run)}`);
+
+ const list = await (await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/artifacts`)).json();
+ assert.deepEqual(
+ list.artifacts.map((a: any) => a.path),
+ ["result.txt"],
+ "the published file is listed",
+ );
+
+ const dl = await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/artifacts/result.txt`);
+ assert.equal(dl.status, 200);
+ assert.match(dl.headers.get("content-type") ?? "", /application\/octet-stream/);
+ assert.match(dl.headers.get("content-disposition") ?? "", /filename="result\.txt"/);
+ assert.equal(Buffer.from(await dl.arrayBuffer()).toString("utf8"), "artifact-payload");
+
+ // Traversal battery: encoded `..`, `%2e%2e`, and an absolute path all 404,
+ // and the run's own run_summary.jsonl (outside artifacts/) is unreachable.
+ for (const escape of ["..%2Frun_summary.jsonl", "%2e%2e%2frun_summary.jsonl", "%2Fetc%2Fpasswd"]) {
+ const res = await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/artifacts/${escape}`);
+ assert.equal(res.status, 404, `traversal ${escape} → 404`);
+ }
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("jaiph --help lists jaiph serve", () => {
const result = spawnSync("node", [CLI_PATH, "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(result.status, 0);
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index b17f87d6..508b2bf6 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { detectWorkspaceRoot } from "../shared/paths";
+import { findRunDir } from "../shared/errors";
import { hasHelpFlag, parseArgs } from "../shared/usage";
import { resolveEnvPairs } from "../run/env";
import { callWorkflow } from "../exec/call";
@@ -31,7 +32,8 @@ const SERVE_USAGE =
"from the `#` comment lines above each workflow. Sources are re-validated on change.\n\n" +
"Endpoints: GET /docs (Swagger UI), GET /openapi.json, GET /healthz, GET /v1/workflows,\n" +
"POST /v1/workflows/{name}/runs (async 202 or ?wait=true for 200), GET /v1/runs,\n" +
- "GET /v1/runs/{id}, POST /v1/runs/{id}/cancel.\n\n" +
+ "GET /v1/runs/{id}, GET /v1/runs/{id}/events (NDJSON, or SSE with Accept: text/event-stream),\n" +
+ "GET /v1/runs/{id}/artifacts, GET /v1/runs/{id}/artifacts/{path}, POST /v1/runs/{id}/cancel.\n\n" +
"Auth: set JAIPH_SERVE_TOKEN to require `Authorization: Bearer ` on every\n" +
"/v1/* request (/healthz, /openapi.json, /docs stay open). Binding a non-loopback\n" +
"host without the token set is a startup error. Cap concurrent runs with\n" +
@@ -145,9 +147,11 @@ export async function runServe(rest: string[]): Promise {
}
let dockerConfig: ReturnType["dockerConfig"];
+ let hostRunsRoot: string;
try {
const posture = resolveStartupPosture(current.state, inputAbs, workspaceRoot, log);
dockerConfig = posture.dockerConfig;
+ hostRunsRoot = posture.hostRunsRoot;
if (dockerConfig.enabled) {
if (posture.sandboxMode === "inplace") {
log(
@@ -183,6 +187,10 @@ export async function runServe(rest: string[]): Promise {
token,
maxConcurrent,
now: () => new Date().toISOString(),
+ // A run's dir is only recorded on its object at finalize; while it runs the
+ // events/artifacts endpoints locate it by scanning the host runs root for
+ // the run id (works host- and Docker-side — the run dir is a host mount).
+ resolveRunDir: (record) => record.run_dir ?? findRunDir(hostRunsRoot, record.run_id),
getTools: () => current.state.tools,
callTool: (spec, args, runId, ctx) => {
// Bind the run to the generation live at start; keep its scripts dir alive
diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts
index 9453326f..db105bdd 100644
--- a/src/cli/serve/handler.test.ts
+++ b/src/cli/serve/handler.test.ts
@@ -1,6 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { ServeHandler, type ServeRequest, type ServeResponse } from "./handler";
+import type { StreamTarget } from "./runfiles";
import type { McpToolSpec } from "../mcp/tools";
import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
@@ -290,3 +294,119 @@ test("cancel on an unknown run is 404; cancel on a terminal run is 409", async (
assert.equal(again.status, 409);
assert.equal(bodyJson(again).error.code, "E_RUN_TERMINAL");
});
+
+// === events + artifacts ===
+
+/** Run `ping` to completion with its `run_dir` pointed at a real temp dir. */
+async function runWithDir(runDir: string): Promise<{ h: ServeHandler; runId: string }> {
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ callTool: async () => ({ text: "ok", isError: false, exitStatus: 0, runDir }),
+ });
+ const started = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true")));
+ return { h, runId: started.run_id };
+}
+
+function fakeTarget(): StreamTarget & { chunks: string[] } {
+ const chunks: string[] = [];
+ return {
+ chunks,
+ write: (c: string) => void chunks.push(c),
+ aborted: false,
+ onAbort: () => {},
+ };
+}
+
+test("events + artifacts on an unknown run id are 404", async () => {
+ const h = makeHandler();
+ for (const path of ["/v1/runs/nope/events", "/v1/runs/nope/artifacts", "/v1/runs/nope/artifacts/x.txt"]) {
+ const res = await h.handleRequest(req("GET", path));
+ assert.equal(res.status, 404, `${path} → 404`);
+ assert.equal(bodyJson(res).error.code, "E_NOT_FOUND");
+ }
+});
+
+test("events + artifacts require the bearer token when one is configured", async () => {
+ const h = makeHandler({ token: "secret" });
+ for (const path of ["/v1/runs/x/events", "/v1/runs/x/artifacts", "/v1/runs/x/artifacts/f"]) {
+ assert.equal((await h.handleRequest(req("GET", path))).status, 401, `${path} → 401`);
+ }
+});
+
+test("NDJSON events on a terminal run byte-match the run_summary.jsonl file", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-ndjson-"));
+ try {
+ const journal = '{"type":"WORKFLOW_START","run_id":"r"}\n{"type":"WORKFLOW_END","run_id":"r"}\n';
+ writeFileSync(join(runDir, "run_summary.jsonl"), journal);
+ const { h, runId } = await runWithDir(runDir);
+ const res = await h.handleRequest(req("GET", `/v1/runs/${runId}/events`));
+ assert.equal(res.status, 200);
+ assert.equal(res.headers["content-type"], "application/x-ndjson");
+ assert.ok(res.bodyBuffer, "NDJSON is served as a binary body");
+ assert.deepEqual(res.bodyBuffer, readFileSync(join(runDir, "run_summary.jsonl")), "byte-identical to the journal");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("SSE events on a terminal run replay the journal then close with event: end", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-sse-"));
+ try {
+ const lines = ['{"type":"WORKFLOW_START","run_id":"r"}', '{"type":"WORKFLOW_END","run_id":"r"}'];
+ writeFileSync(join(runDir, "run_summary.jsonl"), lines.map((l) => `${l}\n`).join(""));
+ const { h, runId } = await runWithDir(runDir);
+ const res = await h.handleRequest(req("GET", `/v1/runs/${runId}/events`, { headers: { accept: "text/event-stream" } }));
+ assert.equal(res.status, 200);
+ assert.equal(res.headers["content-type"], "text/event-stream");
+ assert.ok(res.stream, "SSE is served as a stream");
+ const target = fakeTarget();
+ await res.stream!(target);
+ const dataPayloads = target.chunks.filter((c) => c.startsWith("data: ")).map((c) => c.slice(6).replace(/\n\n$/, ""));
+ assert.deepEqual(dataPayloads, lines);
+ assert.equal(target.chunks[target.chunks.length - 1], "event: end\ndata: {}\n\n");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("artifacts round-trip through the handler: list then byte-identical download", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-art-"));
+ try {
+ mkdirSync(join(runDir, "artifacts"), { recursive: true });
+ const payload = Buffer.from("artifact bytes\n binary", "utf8");
+ writeFileSync(join(runDir, "artifacts", "out.bin"), payload);
+ writeFileSync(join(runDir, "run_summary.jsonl"), "");
+ const { h, runId } = await runWithDir(runDir);
+
+ const list = await h.handleRequest(req("GET", `/v1/runs/${runId}/artifacts`));
+ assert.equal(list.status, 200);
+ assert.deepEqual(
+ bodyJson(list).artifacts.map((a: any) => a.path),
+ ["out.bin"],
+ );
+
+ const dl = await h.handleRequest(req("GET", `/v1/runs/${runId}/artifacts/out.bin`));
+ assert.equal(dl.status, 200);
+ assert.equal(dl.headers["content-type"], "application/octet-stream");
+ assert.match(dl.headers["content-disposition"], /filename="out\.bin"/);
+ assert.deepEqual(dl.bodyBuffer, payload, "downloaded bytes match the published file");
+
+ // Traversal to a run-dir file (not under artifacts/) is a 404.
+ const escape = await h.handleRequest(req("GET", `/v1/runs/${runId}/artifacts/${encodeURIComponent("../run_summary.jsonl")}`));
+ assert.equal(escape.status, 404);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("artifacts list is empty for a run with no published files", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-empty-"));
+ try {
+ writeFileSync(join(runDir, "run_summary.jsonl"), "");
+ const { h, runId } = await runWithDir(runDir);
+ const list = await h.handleRequest(req("GET", `/v1/runs/${runId}/artifacts`));
+ assert.deepEqual(bodyJson(list).artifacts, []);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts
index 7f89fef3..59047180 100644
--- a/src/cli/serve/handler.ts
+++ b/src/cli/serve/handler.ts
@@ -1,8 +1,17 @@
import { randomUUID, timingSafeEqual } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import { basename, join } from "node:path";
import type { McpToolSpec } from "../mcp/tools";
import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
import { buildOpenApi } from "./openapi";
import { DOCS_HTML } from "./docs";
+import {
+ listArtifacts,
+ resolveArtifactPath,
+ streamRunEventsSse,
+ RUN_SUMMARY,
+ type StreamTarget,
+} from "./runfiles";
/** 1 MiB cap on request bodies (design doc). */
export const MAX_BODY_BYTES = 1024 * 1024;
@@ -46,6 +55,15 @@ export interface ServeResponse {
status: number;
headers: Record;
body: string;
+ /** Binary body (artifact download / NDJSON journal); takes precedence over `body`. */
+ bodyBuffer?: Buffer;
+ /**
+ * When set, the HTTP layer streams the body by driving this function instead
+ * of writing `body` — used for the SSE event follow. It resolves when the
+ * stream is complete (run terminal or client gone); the layer then ends the
+ * response.
+ */
+ stream?: (target: StreamTarget) => Promise;
}
export interface ServeHandlerOptions {
@@ -69,6 +87,18 @@ export interface ServeHandlerOptions {
now: () => string;
/** Run-id source, injectable for tests. Defaults to `randomUUID`. */
newRunId?: () => string;
+ /**
+ * Resolve a run's host-side run directory (the one holding
+ * `run_summary.jsonl` and `artifacts/`). Called live — mid-run and after —
+ * for the events/artifacts endpoints. Defaults to the record's own `run_dir`
+ * (populated at finalize); the server supplies a resolver that also scans the
+ * runs root by run id so a still-running run is reachable.
+ */
+ resolveRunDir?: (record: RunRecord) => string | null;
+ /** SSE journal-follow poll interval (ms). Defaults to 250. */
+ ssePollMs?: number;
+ /** SSE keep-alive comment cadence (ms). Defaults to 15000. */
+ sseKeepAliveMs?: number;
}
function isTerminal(status: RunStatus): boolean {
@@ -170,6 +200,24 @@ export class ServeHandler {
return this.json(200, { runs });
}
+ const events = /^\/v1\/runs\/([^/]+)\/events$/.exec(path);
+ if (events) {
+ if (method !== "GET") return this.methodNotAllowed();
+ return this.runEvents(req, decodeURIComponent(events[1]));
+ }
+
+ const artifactsList = /^\/v1\/runs\/([^/]+)\/artifacts$/.exec(path);
+ if (artifactsList) {
+ if (method !== "GET") return this.methodNotAllowed();
+ return this.listRunArtifacts(decodeURIComponent(artifactsList[1]));
+ }
+
+ const artifactGet = /^\/v1\/runs\/([^/]+)\/artifacts\/(.+)$/.exec(path);
+ if (artifactGet) {
+ if (method !== "GET") return this.methodNotAllowed();
+ return this.downloadArtifact(decodeURIComponent(artifactGet[1]), artifactGet[2]);
+ }
+
const cancel = /^\/v1\/runs\/([^/]+)\/cancel$/.exec(path);
if (cancel) {
if (method !== "POST") return this.methodNotAllowed();
@@ -283,6 +331,92 @@ export class ServeHandler {
return { status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify(this.toRunObject(record)) };
}
+ /** Host-side run dir for a record: injected resolver, else its own `run_dir`. */
+ private runDirFor(record: RunRecord): string | null {
+ return this.opts.resolveRunDir ? this.opts.resolveRunDir(record) : record.run_dir;
+ }
+
+ /**
+ * `GET /v1/runs/{id}/events`. Default: the run's `run_summary.jsonl` as
+ * `application/x-ndjson`, verbatim, then close. `Accept: text/event-stream`:
+ * SSE replay + live follow until the run is terminal. The journal's own
+ * redaction is the redaction guarantee; raw capture files are never served.
+ */
+ private runEvents(req: ServeRequest, id: string): ServeResponse {
+ const record = this.runs.get(id);
+ if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
+ const wantsSse = (req.headers["accept"] ?? "").includes("text/event-stream");
+ const resolveRunDir = (): string | null => this.runDirFor(record);
+ if (!wantsSse) {
+ const dir = resolveRunDir();
+ const file = dir ? join(dir, RUN_SUMMARY) : null;
+ const bodyBuffer = file && existsSync(file) ? readFileSync(file) : Buffer.alloc(0);
+ return { status: 200, headers: { "content-type": "application/x-ndjson" }, body: "", bodyBuffer };
+ }
+ return {
+ status: 200,
+ headers: {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ },
+ body: "",
+ stream: (target) =>
+ streamRunEventsSse(target, {
+ resolveRunDir,
+ isTerminal: () => isTerminal(record.status),
+ pollMs: this.opts.ssePollMs ?? 250,
+ keepAliveMs: this.opts.sseKeepAliveMs ?? 15000,
+ }),
+ };
+ }
+
+ /** `GET /v1/runs/{id}/artifacts`: JSON list of published files (empty when none). */
+ private listRunArtifacts(id: string): ServeResponse {
+ const record = this.runs.get(id);
+ if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
+ const dir = this.runDirFor(record);
+ return this.json(200, { artifacts: dir ? listArtifacts(dir) : [] });
+ }
+
+ /**
+ * `GET /v1/runs/{id}/artifacts/{path}`: download one published file as
+ * `application/octet-stream`. Traversal-proof — anything escaping the run's
+ * `artifacts/` dir (`..`, absolute paths, escaping symlinks) is a 404, and
+ * raw `%06d-*.out`/`.err` capture files (run-dir root, not `artifacts/`) are
+ * unreachable by construction.
+ */
+ private downloadArtifact(id: string, rawPath: string): ServeResponse {
+ const record = this.runs.get(id);
+ if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
+ const dir = this.runDirFor(record);
+ if (!dir) return this.error(404, "E_NOT_FOUND", "unknown artifact");
+ let requested: string;
+ try {
+ requested = decodeURIComponent(rawPath);
+ } catch {
+ return this.error(404, "E_NOT_FOUND", "unknown artifact");
+ }
+ const abs = resolveArtifactPath(dir, requested);
+ if (!abs) return this.error(404, "E_NOT_FOUND", "unknown artifact");
+ let bodyBuffer: Buffer;
+ try {
+ bodyBuffer = readFileSync(abs);
+ } catch {
+ return this.error(404, "E_NOT_FOUND", "unknown artifact");
+ }
+ const filename = basename(abs).replace(/"/g, "");
+ return {
+ status: 200,
+ headers: {
+ "content-type": "application/octet-stream",
+ "content-disposition": `attachment; filename="${filename}"`,
+ },
+ body: "",
+ bodyBuffer,
+ };
+ }
+
private finalize(record: RunRecord, result: WorkflowCallResult): void {
record.ended_at = this.opts.now();
record.exit_status = result.exitStatus ?? null;
diff --git a/src/cli/serve/openapi.ts b/src/cli/serve/openapi.ts
index b5948e99..eb87cc1b 100644
--- a/src/cli/serve/openapi.ts
+++ b/src/cli/serve/openapi.ts
@@ -177,6 +177,89 @@ export function buildOpenApi(tools: McpToolSpec[], serverInfo: OpenApiServerInfo
},
};
+ paths["/v1/runs/{id}/events"] = {
+ get: {
+ operationId: "getRunEvents",
+ summary: "Stream a run's event journal",
+ description:
+ "The run's durable `run_summary.jsonl`. Default: `application/x-ndjson` snapshot. " +
+ "With `Accept: text/event-stream`, Server-Sent Events replay the journal then follow it " +
+ "live until the run is terminal (`event: end`). The journal is served verbatim — already " +
+ "credential-redacted; raw step capture files are never exposed.",
+ security: BEARER_SECURITY,
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
+ responses: {
+ "200": {
+ description: "The event journal (NDJSON snapshot or an SSE stream).",
+ content: {
+ "application/x-ndjson": { schema: { type: "string" } },
+ "text/event-stream": { schema: { type: "string" } },
+ },
+ },
+ "401": errorResponse("Missing or invalid bearer token."),
+ "404": errorResponse("Unknown run id."),
+ },
+ },
+ };
+
+ paths["/v1/runs/{id}/artifacts"] = {
+ get: {
+ operationId: "listRunArtifacts",
+ summary: "List a run's published artifacts",
+ security: BEARER_SECURITY,
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
+ responses: {
+ "200": {
+ description: "Files published under the run's artifacts directory (empty when none).",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ artifacts: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ size: { type: "integer" },
+ mtime: { type: "string" },
+ },
+ required: ["path", "size", "mtime"],
+ },
+ },
+ },
+ required: ["artifacts"],
+ },
+ },
+ },
+ },
+ "401": errorResponse("Missing or invalid bearer token."),
+ "404": errorResponse("Unknown run id."),
+ },
+ },
+ };
+
+ paths["/v1/runs/{id}/artifacts/{path}"] = {
+ get: {
+ operationId: "downloadRunArtifact",
+ summary: "Download one published artifact",
+ security: BEARER_SECURITY,
+ parameters: [
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
+ { name: "path", in: "path", required: true, schema: { type: "string" } },
+ ],
+ responses: {
+ "200": {
+ description: "The artifact bytes.",
+ content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
+ },
+ "401": errorResponse("Missing or invalid bearer token."),
+ "404": errorResponse("Unknown run id or artifact (including any path traversal attempt)."),
+ },
+ },
+ };
+
paths["/v1/runs/{id}/cancel"] = {
post: {
operationId: "cancelRun",
diff --git a/src/cli/serve/runfiles.test.ts b/src/cli/serve/runfiles.test.ts
new file mode 100644
index 00000000..81249ced
--- /dev/null
+++ b/src/cli/serve/runfiles.test.ts
@@ -0,0 +1,211 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { listArtifacts, resolveArtifactPath, streamRunEventsSse, type StreamTarget } from "./runfiles";
+
+/** A run dir with an `artifacts/` subdir and a couple of raw capture files at the root. */
+function makeRunDir(): string {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-runfiles-"));
+ const artifacts = join(runDir, "artifacts");
+ mkdirSync(artifacts, { recursive: true });
+ // Raw step capture files live in the run-dir ROOT, never under artifacts/.
+ writeFileSync(join(runDir, "000001-build__step.out"), "SECRET stdout capture\n");
+ writeFileSync(join(runDir, "000001-build__step.err"), "stderr capture\n");
+ writeFileSync(join(runDir, "run_summary.jsonl"), "");
+ return runDir;
+}
+
+// === listArtifacts ===
+
+test("listArtifacts returns [] when there is no artifacts dir", () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-noart-"));
+ try {
+ assert.deepEqual(listArtifacts(runDir), []);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("listArtifacts lists regular files recursively and never the .out/.err captures", () => {
+ const runDir = makeRunDir();
+ try {
+ writeFileSync(join(runDir, "artifacts", "report.txt"), "hello");
+ mkdirSync(join(runDir, "artifacts", "nested"), { recursive: true });
+ writeFileSync(join(runDir, "artifacts", "nested", "deep.bin"), Buffer.from([1, 2, 3]));
+ const entries = listArtifacts(runDir);
+ assert.deepEqual(
+ entries.map((e) => e.path),
+ ["nested/deep.bin", "report.txt"],
+ "recursive, sorted, POSIX-relative; no capture files",
+ );
+ const report = entries.find((e) => e.path === "report.txt")!;
+ assert.equal(report.size, 5);
+ assert.equal(typeof report.mtime, "string");
+ // The capture files exist in the run dir but must never surface as artifacts.
+ assert.ok(!entries.some((e) => e.path.endsWith(".out") || e.path.endsWith(".err")));
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+// === resolveArtifactPath: traversal battery ===
+
+test("resolveArtifactPath serves a contained regular file (and a nested one)", () => {
+ const runDir = makeRunDir();
+ try {
+ writeFileSync(join(runDir, "artifacts", "ok.txt"), "data");
+ mkdirSync(join(runDir, "artifacts", "sub"), { recursive: true });
+ writeFileSync(join(runDir, "artifacts", "sub", "inner.txt"), "data2");
+ assert.equal(resolveArtifactPath(runDir, "ok.txt"), realpathSync(join(runDir, "artifacts", "ok.txt")));
+ assert.equal(resolveArtifactPath(runDir, "sub/inner.txt"), realpathSync(join(runDir, "artifacts", "sub", "inner.txt")));
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("resolveArtifactPath rejects a ../-containing path escaping artifacts/", () => {
+ const runDir = makeRunDir();
+ try {
+ // Points at a real capture file in the run-dir root — must still be null.
+ assert.equal(resolveArtifactPath(runDir, "../000001-build__step.out"), null);
+ assert.equal(resolveArtifactPath(runDir, "../../etc/hosts"), null);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("resolveArtifactPath rejects an absolute path", () => {
+ const runDir = makeRunDir();
+ try {
+ assert.equal(resolveArtifactPath(runDir, "/etc/passwd"), null);
+ assert.equal(resolveArtifactPath(runDir, join(runDir, "run_summary.jsonl")), null);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("resolveArtifactPath rejects an empty or NUL-bearing request and a directory", () => {
+ const runDir = makeRunDir();
+ try {
+ mkdirSync(join(runDir, "artifacts", "adir"), { recursive: true });
+ assert.equal(resolveArtifactPath(runDir, ""), null);
+ assert.equal(resolveArtifactPath(runDir, "a\0b"), null);
+ assert.equal(resolveArtifactPath(runDir, "adir"), null, "a directory is not a downloadable artifact");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("resolveArtifactPath rejects a symlink escaping artifacts/ without reading the target", () => {
+ const runDir = makeRunDir();
+ try {
+ // A symlink INSIDE artifacts pointing OUTSIDE (at a run-dir capture file).
+ symlinkSync(join(runDir, "000001-build__step.out"), join(runDir, "artifacts", "escape.txt"));
+ assert.equal(resolveArtifactPath(runDir, "escape.txt"), null);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("resolveArtifactPath serves a symlink whose target is inside artifacts/", () => {
+ const runDir = makeRunDir();
+ try {
+ writeFileSync(join(runDir, "artifacts", "real.txt"), "payload");
+ symlinkSync(join(runDir, "artifacts", "real.txt"), join(runDir, "artifacts", "link.txt"));
+ const resolved = resolveArtifactPath(runDir, "link.txt");
+ assert.equal(resolved, realpathSync(join(runDir, "artifacts", "real.txt")), "resolves to the contained real target");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("no capture file is reachable: an artifacts/ symlink to a .out is rejected", () => {
+ const runDir = makeRunDir();
+ try {
+ symlinkSync(join(runDir, "000001-build__step.out"), join(runDir, "artifacts", "leak.out"));
+ assert.equal(resolveArtifactPath(runDir, "leak.out"), null, "cannot download a capture file via a symlink");
+ // And a direct traversal to either capture extension is rejected too.
+ assert.equal(resolveArtifactPath(runDir, "../000001-build__step.out"), null);
+ assert.equal(resolveArtifactPath(runDir, "../000001-build__step.err"), null);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+// === SSE framing ===
+
+/** Collect every write; report `aborted` and run abort callbacks on demand. */
+function fakeTarget(): StreamTarget & { chunks: string[]; abort: () => void } {
+ let aborted = false;
+ const cbs: Array<() => void> = [];
+ const chunks: string[] = [];
+ return {
+ chunks,
+ write(chunk: string): void {
+ chunks.push(chunk);
+ },
+ get aborted(): boolean {
+ return aborted;
+ },
+ onAbort(cb: () => void): void {
+ cbs.push(cb);
+ },
+ abort(): void {
+ aborted = true;
+ for (const cb of cbs) cb();
+ },
+ };
+}
+
+test("streamRunEventsSse replays a terminal run's journal as data: frames then event: end", async () => {
+ const runDir = makeRunDir();
+ try {
+ const lines = [
+ '{"type":"WORKFLOW_START","run_id":"r1"}',
+ '{"type":"STEP_END","status":0}',
+ '{"type":"WORKFLOW_END","run_id":"r1"}',
+ ];
+ writeFileSync(join(runDir, "run_summary.jsonl"), lines.map((l) => `${l}\n`).join(""));
+ const target = fakeTarget();
+ await streamRunEventsSse(target, {
+ resolveRunDir: () => runDir,
+ isTerminal: () => true,
+ pollMs: 5,
+ keepAliveMs: 15000,
+ });
+ // Every journal line arrives as its own `data:` frame, in order (the end
+ // frame is a distinct `event: end` chunk, not a `data: ` one).
+ const dataPayloads = target.chunks
+ .filter((c) => c.startsWith("data: "))
+ .map((c) => c.slice("data: ".length).replace(/\n\n$/, ""));
+ assert.deepEqual(dataPayloads, lines, "concatenated data: payloads equal the journal line set");
+ assert.ok(target.chunks.some((c) => c === "event: end\ndata: {}\n\n"), "closes with event: end");
+ assert.equal(target.chunks[target.chunks.length - 1], "event: end\ndata: {}\n\n", "end frame is last");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("streamRunEventsSse stops promptly when the client disconnects mid-run", async () => {
+ const runDir = makeRunDir();
+ try {
+ writeFileSync(join(runDir, "run_summary.jsonl"), '{"type":"WORKFLOW_START","run_id":"r1"}\n');
+ const target = fakeTarget();
+ // Never terminal: the loop would poll forever, but an abort ends it.
+ const done = streamRunEventsSse(target, {
+ resolveRunDir: () => runDir,
+ isTerminal: () => false,
+ pollMs: 10,
+ keepAliveMs: 15000,
+ });
+ target.abort();
+ await done;
+ // The initial replay happened; no end frame (client left before terminal).
+ assert.ok(target.chunks.some((c) => c.startsWith("data: ")));
+ assert.ok(!target.chunks.some((c) => c.startsWith("event: end")));
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/serve/runfiles.ts b/src/cli/serve/runfiles.ts
new file mode 100644
index 00000000..adb84abe
--- /dev/null
+++ b/src/cli/serve/runfiles.ts
@@ -0,0 +1,183 @@
+import { existsSync, readFileSync, readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
+import { join, resolve, sep } from "node:path";
+
+/** A run's durable journal file name, under its run directory. */
+export const RUN_SUMMARY = "run_summary.jsonl";
+
+/**
+ * A published artifact: a regular file under a run's `artifacts/` directory,
+ * described by its path relative to that directory (POSIX-style `/` separators),
+ * byte size, and last-modified time.
+ */
+export interface ArtifactEntry {
+ path: string;
+ size: number;
+ mtime: string;
+}
+
+/**
+ * List every regular file under `/artifacts/` (recursively), newest
+ * paths sorted lexicographically, `[]` when the directory is absent or empty.
+ *
+ * Symlinks are skipped: `withFileTypes` reports the entry's own type (an
+ * `lstat`, never following the link), so a symlink is neither listed as a file
+ * nor descended into as a directory. Only the durable `artifacts/` tree is
+ * walked — the raw `%06d-*.out`/`.err` capture files live in the run-dir root
+ * and are never reached by this function.
+ */
+export function listArtifacts(runDir: string): ArtifactEntry[] {
+ const root = join(runDir, "artifacts");
+ const out: ArtifactEntry[] = [];
+ const walk = (dir: string, prefix: string): void => {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
+ const abs = join(dir, entry.name);
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ walk(abs, rel);
+ } else if (entry.isFile()) {
+ try {
+ const st = statSync(abs);
+ out.push({ path: rel, size: st.size, mtime: st.mtime.toISOString() });
+ } catch {
+ // File vanished between readdir and stat; skip it.
+ }
+ }
+ // Symlinks and other node types are intentionally ignored.
+ }
+ };
+ walk(root, "");
+ return out;
+}
+
+/**
+ * Resolve a requested artifact sub-path against `/artifacts/`,
+ * traversal-proof, returning the absolute real path of a contained regular file
+ * or `null` if the request escapes the artifacts directory in any way.
+ *
+ * Three independent guards, checked before the file is ever read:
+ * 1. Empty / NUL-bearing requests are rejected outright.
+ * 2. Lexical containment: `resolve()` collapses `..` and absolute paths, then
+ * the result must sit strictly under the artifacts dir.
+ * 3. Symlink containment: `realpathSync` resolves every symlink on the way, and
+ * the real target must still sit under the real artifacts dir — a symlink
+ * inside `artifacts/` pointing outside is rejected without reading its target.
+ */
+export function resolveArtifactPath(runDir: string, requested: string): string | null {
+ if (requested.length === 0 || requested.includes("\0")) return null;
+ const artifactsDir = join(runDir, "artifacts");
+ const candidate = resolve(artifactsDir, requested);
+ if (candidate !== artifactsDir && !candidate.startsWith(artifactsDir + sep)) return null;
+ if (!existsSync(candidate)) return null;
+ let realArtifacts: string;
+ let realCandidate: string;
+ try {
+ realArtifacts = realpathSync(artifactsDir);
+ realCandidate = realpathSync(candidate);
+ } catch {
+ return null;
+ }
+ if (realCandidate !== realArtifacts && !realCandidate.startsWith(realArtifacts + sep)) return null;
+ let st: ReturnType;
+ try {
+ st = statSync(realCandidate);
+ } catch {
+ return null;
+ }
+ if (!st.isFile()) return null;
+ return realCandidate;
+}
+
+/** The response-body sink an SSE stream writes to; decoupled from `node:http`. */
+export interface StreamTarget {
+ write(chunk: string): void;
+ readonly aborted: boolean;
+ onAbort(cb: () => void): void;
+}
+
+/** Inputs to the SSE follower — injectable so it is unit-testable without a socket. */
+export interface SseEventsOptions {
+ /** Re-resolves the run dir each poll (it may appear after the run starts). */
+ resolveRunDir: () => string | null;
+ /** True once the server's registry has marked the run terminal. */
+ isTerminal: () => boolean;
+ /** File-follow poll interval (ms). */
+ pollMs: number;
+ /** Keep-alive comment cadence (ms). */
+ keepAliveMs: number;
+}
+
+/** Read complete (newline-terminated) lines from `file` starting at byte `offset`. */
+function readNewLines(file: string, offset: number): { lines: string[]; nextOffset: number } {
+ let buf: Buffer;
+ try {
+ buf = readFileSync(file);
+ } catch {
+ return { lines: [], nextOffset: offset };
+ }
+ if (buf.length <= offset) return { lines: [], nextOffset: offset };
+ const chunk = buf.toString("utf8", offset);
+ const lastNl = chunk.lastIndexOf("\n");
+ if (lastNl === -1) return { lines: [], nextOffset: offset };
+ const complete = chunk.slice(0, lastNl);
+ const lines = complete.length > 0 ? complete.split("\n") : [];
+ const nextOffset = offset + Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
+ return { lines, nextOffset };
+}
+
+/**
+ * SSE follower for a run's durable journal. Replays every existing line as
+ * `data: `, follows the file as it appends (polling every
+ * `pollMs`), keeps proxies from idling the connection out with a `:ka` comment
+ * every `keepAliveMs`, and closes with `event: end` once the run is terminal —
+ * so it works identically for a still-running run and an already-terminal one
+ * (full replay + immediate end).
+ *
+ * The journal is streamed verbatim: the credential redaction already applied by
+ * `RuntimeEventEmitter` is the redaction guarantee, and the raw `%06d-*.out`/
+ * `.err` capture files are never opened here.
+ */
+export async function streamRunEventsSse(target: StreamTarget, opts: SseEventsOptions): Promise {
+ let offset = 0;
+ let lastKeepAlive = Date.now();
+ const flush = (): void => {
+ const dir = opts.resolveRunDir();
+ if (!dir) return;
+ const { lines, nextOffset } = readNewLines(join(dir, RUN_SUMMARY), offset);
+ offset = nextOffset;
+ for (const line of lines) target.write(`data: ${line}\n\n`);
+ };
+ for (;;) {
+ if (target.aborted) return;
+ flush();
+ if (opts.isTerminal()) {
+ // A final line (e.g. WORKFLOW_END) may have landed between the read above
+ // and the registry marking the run terminal; flush once more so the
+ // stream is complete before closing.
+ flush();
+ target.write("event: end\ndata: {}\n\n");
+ return;
+ }
+ if (Date.now() - lastKeepAlive >= opts.keepAliveMs) {
+ target.write(":ka\n\n");
+ lastKeepAlive = Date.now();
+ }
+ await sleep(opts.pollMs, target);
+ }
+}
+
+/** Sleep `ms`, resolving early if the client disconnects. */
+function sleep(ms: number, target: StreamTarget): Promise {
+ return new Promise((resolveSleep) => {
+ const timer = setTimeout(resolveSleep, ms);
+ target.onAbort(() => {
+ clearTimeout(timer);
+ resolveSleep();
+ });
+ });
+}
diff --git a/src/cli/serve/server.ts b/src/cli/serve/server.ts
index fda1efd5..c9c80fee 100644
--- a/src/cli/serve/server.ts
+++ b/src/cli/serve/server.ts
@@ -1,6 +1,7 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { MAX_BODY_BYTES, type ServeHandler, type ServeRequest } from "./handler";
+import type { StreamTarget } from "./runfiles";
/**
* Wire a `node:http` server to a `ServeHandler`. This is the only place that
@@ -26,7 +27,16 @@ export function createHttpServer(handler: ServeHandler, log: (line: string) => v
})
.then((response) => {
res.writeHead(response.status, response.headers);
- res.end(response.body);
+ if (response.stream) {
+ // Long-lived streaming response (SSE follow): drive it over a target
+ // wired to this socket, then end when it resolves.
+ const target = makeStreamTarget(req, res);
+ return response.stream(target).then(() => {
+ res.end();
+ });
+ }
+ res.end(response.bodyBuffer ?? response.body);
+ return undefined;
})
.catch((err) => {
log(`jaiph serve: request handling failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -38,6 +48,36 @@ export function createHttpServer(handler: ServeHandler, log: (line: string) => v
});
}
+/**
+ * Wire a `StreamTarget` to a live socket: writes go straight to the response,
+ * and the client disconnecting (`req` close) flips `aborted` and fires the
+ * registered callbacks so a follow loop stops promptly instead of writing to a
+ * dead socket.
+ */
+function makeStreamTarget(req: IncomingMessage, res: ServerResponse): StreamTarget {
+ let aborted = false;
+ const onAbortCbs: Array<() => void> = [];
+ const abort = (): void => {
+ if (aborted) return;
+ aborted = true;
+ for (const cb of onAbortCbs) cb();
+ };
+ req.on("close", abort);
+ res.on("close", abort);
+ return {
+ write(chunk: string): void {
+ if (!aborted && res.writable) res.write(chunk);
+ },
+ get aborted(): boolean {
+ return aborted;
+ },
+ onAbort(cb: () => void): void {
+ if (aborted) cb();
+ else onAbortCbs.push(cb);
+ },
+ };
+}
+
/** Read the request body as a string, flagging (and truncating) once it passes the cap. */
function readBody(req: IncomingMessage): Promise<{ body: string; tooLarge: boolean }> {
return new Promise((resolve, reject) => {
diff --git a/src/cli/shared/errors.ts b/src/cli/shared/errors.ts
index f7c13888..9879aafa 100644
--- a/src/cli/shared/errors.ts
+++ b/src/cli/shared/errors.ts
@@ -223,18 +223,21 @@ export function readFailedStepOutput(summaryPath: string): string | null {
}
/**
- * Discover run directory from the Docker sandbox runs mount.
- * In Docker mode the container's meta file is inaccessible from the host,
- * so we scan the bind-mounted sandboxRunDir for the latest run directory.
+ * Locate a run's host-side directory under `runsRoot` by matching `runId`
+ * against the `WORKFLOW_START` line (always the first line) of each candidate
+ * `run_summary.jsonl`. Works while the run is still executing (that line is
+ * written at run start) and in every sandbox mode (the run dir is a host mount
+ * even under Docker). Scans date/time directories newest-first; returns `null`
+ * when no run dir matches or `runsRoot` is unreadable.
*/
-export function discoverDockerRunDir(sandboxRunDir: string, expectedRunId: string): { runDir?: string; summaryFile?: string } {
+export function findRunDir(runsRoot: string, runId: string): string | null {
try {
- const dateDirs = readdirSync(sandboxRunDir)
- .filter((d) => !d.startsWith(".") && statSync(join(sandboxRunDir, d)).isDirectory())
+ const dateDirs = readdirSync(runsRoot)
+ .filter((d) => !d.startsWith(".") && statSync(join(runsRoot, d)).isDirectory())
.sort()
.reverse();
for (const dateDir of dateDirs) {
- const datePath = join(sandboxRunDir, dateDir);
+ const datePath = join(runsRoot, dateDir);
const timeDirs = readdirSync(datePath)
.filter((d) => statSync(join(datePath, d)).isDirectory())
.sort()
@@ -247,8 +250,8 @@ export function discoverDockerRunDir(sandboxRunDir: string, expectedRunId: strin
if (!firstLine) continue;
try {
const parsed = JSON.parse(firstLine) as { type?: string; run_id?: string };
- if (parsed.type === "WORKFLOW_START" && parsed.run_id === expectedRunId) {
- return { runDir, summaryFile };
+ if (parsed.type === "WORKFLOW_START" && parsed.run_id === runId) {
+ return runDir;
}
} catch {
// ignore malformed JSON
@@ -256,9 +259,20 @@ export function discoverDockerRunDir(sandboxRunDir: string, expectedRunId: strin
}
}
} catch {
- // ignore — sandboxRunDir may not exist or be readable
+ // ignore — runsRoot may not exist or be readable
}
- return {};
+ return null;
+}
+
+/**
+ * Discover run directory from the Docker sandbox runs mount.
+ * In Docker mode the container's meta file is inaccessible from the host,
+ * so we scan the bind-mounted sandboxRunDir for the matching run directory.
+ */
+export function discoverDockerRunDir(sandboxRunDir: string, expectedRunId: string): { runDir?: string; summaryFile?: string } {
+ const runDir = findRunDir(sandboxRunDir, expectedRunId);
+ if (!runDir) return {};
+ return { runDir, summaryFile: join(runDir, "run_summary.jsonl") };
}
/** Remap a container-internal path to the equivalent host path. */
diff --git a/src/cli/shared/generation.ts b/src/cli/shared/generation.ts
index 6e35fbac..77d97bed 100644
--- a/src/cli/shared/generation.ts
+++ b/src/cli/shared/generation.ts
@@ -9,6 +9,7 @@ import {
checkDockerAvailable,
prepareImage,
selectMcpSandboxMode,
+ resolveDockerHostRunsRoot,
type DockerRunConfig,
type SandboxMode,
} from "../../runtime/docker";
@@ -88,7 +89,7 @@ export function resolveStartupPosture(
inputAbs: string,
workspaceRoot: string,
log: (line: string) => void,
-): { dockerConfig: DockerRunConfig; sandboxMode: SandboxMode } {
+): { dockerConfig: DockerRunConfig; sandboxMode: SandboxMode; hostRunsRoot: string } {
const mod = state.graph.modules.get(inputAbs)!.ast;
const startupEnv = resolveRuntimeEnv(state.callEnv.effectiveConfig, workspaceRoot, inputAbs);
const dockerConfig = resolveDockerConfig(resolveModuleMetadata(mod, process.env)?.runtime, startupEnv);
@@ -107,7 +108,22 @@ export function resolveStartupPosture(
dockerEnabled: dockerConfig.enabled,
});
for (const w of [...credPreflight.warnings, ...credPreflight.errors]) log(w);
- return { dockerConfig, sandboxMode };
+ // Host-visible runs root (same formula the runtime uses to place a run dir).
+ // Docker keeps it within the workspace so the bind mount can expose it; host
+ // mode allows an out-of-workspace absolute `JAIPH_RUNS_DIR`.
+ const hostRunsRoot = dockerConfig.enabled
+ ? resolveDockerHostRunsRoot(workspaceRoot, startupEnv)
+ : resolveHostRunsRoot(workspaceRoot, startupEnv);
+ return { dockerConfig, sandboxMode, hostRunsRoot };
+}
+
+/** Host runs root: absolute `JAIPH_RUNS_DIR` as-is, relative under the workspace, else `.jaiph/runs`. */
+function resolveHostRunsRoot(workspaceRoot: string, env: Record): string {
+ const configured = env.JAIPH_RUNS_DIR;
+ if (configured && configured.length > 0) {
+ return configured.startsWith("/") ? configured : join(workspaceRoot, configured);
+ }
+ return join(workspaceRoot, ".jaiph", "runs");
}
/**
diff --git a/src/cli/shared/usage.ts b/src/cli/shared/usage.ts
index 710ff6cb..a9b71471 100644
--- a/src/cli/shared/usage.ts
+++ b/src/cli/shared/usage.ts
@@ -67,7 +67,9 @@ export function printUsage(): void {
"jaiph serve:",
" Serve the file's workflows as an HTTP API with a generated OpenAPI 3.1 document",
" and Swagger UI at /docs. Exposure mirrors `jaiph mcp`. Runs are durable resources",
- " under .jaiph/runs/, inspectable via GET /v1/runs/{id}. Set JAIPH_SERVE_TOKEN to",
+ " under .jaiph/runs/: inspect one with GET /v1/runs/{id}, stream its event journal",
+ " (NDJSON or SSE) via GET /v1/runs/{id}/events, and list/download published files via",
+ " GET /v1/runs/{id}/artifacts[/{path}]. Set JAIPH_SERVE_TOKEN to",
" require a bearer token on /v1/*; binding a non-loopback --host without it is a",
" startup error. JAIPH_SERVE_MAX_CONCURRENT (default 4) caps simultaneous runs.",
" --host listen address (default: 127.0.0.1)",
From a604ee33c58a5906d4b9cf9ce4c650064a8cba9b Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 09:16:12 +0200
Subject: [PATCH 03/24] Feat: export run traces to an OTLP collector
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A completed run now exports one OpenTelemetry span tree (workflow → steps
→ prompts) to any OTLP/HTTP collector — a local otel-collector, Grafana
Tempo, Honeycomb, Datadog — enabled by the standard OTEL_EXPORTER_OTLP_*
environment variables. Export happens host-side after the run reaches
terminal state by reading the run's already credential-redacted
run_summary.jsonl, so nothing new crosses the sandbox boundary and no
OTEL_* env is forwarded into the container.
The new src/cli/telemetry/otlp.ts adds zero runtime dependencies (a
node:https/node:http request is the whole transport). The pure
runSummaryToOtlp() maps journal lines to a JSON ExportTraceServiceRequest
with deterministic trace/span ids, and a single shared exportRunTelemetry()
hook fires at every host terminal state (jaiph run completion and the
shared workflow-call layer covering MCP and jaiph serve). Telemetry is
never load-bearing: an unreachable or erroring collector produces exactly
one stderr warning and leaves the run's exit code, output, and journal
untouched.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
QUEUE.md | 35 ---
README.md | 3 +-
docs/_layouts/docs.html | 1 +
docs/env-vars.md | 18 ++
docs/index.html | 5 +
docs/observability.md | 103 +++++++++
integration/otlp-export.test.ts | 327 ++++++++++++++++++++++++++
src/cli/commands/run.ts | 13 +-
src/cli/exec/call.ts | 18 +-
src/cli/telemetry/otlp.test.ts | 266 ++++++++++++++++++++++
src/cli/telemetry/otlp.ts | 390 ++++++++++++++++++++++++++++++++
12 files changed, 1139 insertions(+), 44 deletions(-)
create mode 100644 docs/observability.md
create mode 100644 integration/otlp-export.test.ts
create mode 100644 src/cli/telemetry/otlp.test.ts
create mode 100644 src/cli/telemetry/otlp.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34b840b9..38cc63fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,12 @@
- **Serve workflows over HTTP:** run `jaiph serve ./tools.jh` to expose a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so a CI job, a Kubernetes deployment, or any HTTP client can invoke tested workflows and inspect their runs — no MCP client or local jaiph install required. Bearer-token auth, a concurrency cap, and the same durable `.jaiph/runs/` records as `jaiph run` are built in.
+- **Export traces to an OTLP collector:** every run now exports one OpenTelemetry span tree (workflow → steps → prompts) to any OTLP/HTTP collector — a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog — enabled by the standard `OTEL_EXPORTER_OTLP_ENDPOINT`. Export is host-side and end-of-run (it reads the run's already credential-redacted `run_summary.jsonl`), adds no runtime dependencies, and is never load-bearing: an unreachable or erroring collector produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched.
+
## All changes
+- **Feat — OTLP trace export: one span tree per run, zero dependencies:** A completed run now exports as an OpenTelemetry trace to any OTLP/HTTP collector (a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog), so operators running workflows in CI or as a service get a per-run latency breakdown and failure signal in a standard observability stack instead of only the local run dir. Export is **host-side and end-of-run**, in the new `src/cli/telemetry/otlp.ts` (zero runtime dependencies — a `node:https`/`node:http` request is the whole transport): after a run reaches terminal state the CLI reads *that run's* `run_summary.jsonl` and posts one trace. Nothing new crosses the sandbox boundary and no `OTEL_*` variable is forwarded into the container — the journal is complete (it carries the `WORKFLOW_*`/`PROMPT_*` records the live stderr stream omits), already credential-redacted by `RuntimeEventEmitter`, and host-visible in every sandbox mode (the run dir is a host mount); runs are minutes long, so end-of-run batching is the normal OTLP pattern anyway. The pure `runSummaryToOtlp(lines, meta)` maps journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`: **trace id** = the run-id UUID with dashes stripped (32 hex, per the spec's JSON mapping), **span id** = first 16 hex of `sha256()` — deterministic, so re-exporting a run yields byte-identical ids. One **root span** `workflow ` per run (`WORKFLOW_START`→`WORKFLOW_END` timestamps, falling back to first/last event `ts`; status `ERROR` when the run exits nonzero or a signal killed it, else `OK`; each `LOGERR`/`LOGWARN` becomes a span event on it); one **step span** per `STEP_START`/`STEP_END` pair matched by event `id`, parented via `parent_id` (root when null), `kind: SPAN_KIND_INTERNAL`, attributes `jaiph.step.{kind,func,name,seq,depth,status,elapsed_ms}` plus the redacted `jaiph.step.out`/`jaiph.step.err` captures, span status `ERROR` on a nonzero step status; **prompt spans** as children of their `step_id` with `jaiph.prompt.{backend,model,status}`. A `STEP_START` with no matching `STEP_END` (a crash) closes at the last event's `ts` with status `ERROR`; ISO `ts` → `timeUnixNano` strings. Resource attributes: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), the `OTEL_RESOURCE_ATTRIBUTES` pairs, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`. **Enablement is standard OTEL env — no new `JAIPH_*`:** export runs iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set (traces-specific wins when both are); `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) is applied; only `http/json` is spoken — any other `OTEL_EXPORTER_OTLP_PROTOCOL` (e.g. `grpc`) warns on stderr and skips rather than mis-speak a protocol. A single shared hook `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})` fires at every host terminal state — `jaiph run` completion (`reportResult` in `src/cli/commands/run.ts`) and the shared workflow-call layer (`src/cli/exec/call.ts`, covering every MCP `tools/call` and `jaiph serve` invocation) — one choke point covering host, Docker snapshot, and inplace modes. **Telemetry is never load-bearing:** an unreachable or erroring collector (or the 10 s timeout) produces exactly one stderr warning line and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/otlp.test.ts` (fixture journals — trace id from run id, step parenting per `parent_id`, prompt child of `step_id` with backend/model, failed step → span status 2, nonzero exit → root status 2, ISO→nano strings, unmatched `STEP_START` closes ERROR at the last event time, deterministic ids across two invocations; endpoint-resolution precedence, header parsing, `http/json`-only guard) and `integration/otlp-export.test.ts` (a fake collector receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set, and nothing with no OTEL env; a `500` and a connection-refused collector each leave exit `0` with exactly one stderr warning; a shared-call/MCP invocation triggers exactly one export per call; a credential echoed in step output appears in the payload only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: new [Export traces to an OTLP collector](observability.md) how-to, a "Telemetry variables" section in [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — "OTEL + Sentry configurable would be the easiest way".)
+
- **Feat — `jaiph serve` run inspection: live event stream + artifact download:** A `jaiph serve` client can now watch what a run is doing while it executes and retrieve the files it publishes, not just read its terminal result. Three new bearer-gated endpoints (`src/cli/serve/runfiles.ts` + routes in `src/cli/serve/handler.ts`, documented in the generated OpenAPI via `src/cli/serve/openapi.ts`) read from the run's durable, host-visible run directory — the same `run_summary.jsonl` journal and `artifacts/` tree `jaiph run` writes. **`GET /v1/runs/{id}/events`** serves the run's `run_summary.jsonl`: by default the whole journal as `application/x-ndjson`, verbatim, then closes; with `Accept: text/event-stream` it switches to **Server-Sent Events** — `streamRunEventsSse` replays every existing line as `data: `, then follows the file as it appends (polling ~250 ms), emits a `:ka` keep-alive comment every 15 s, and closes with `event: end` once the server's run registry marks the run terminal (so it works identically for a still-running run and an already-terminal one: full replay plus an immediate `end`). **`GET /v1/runs/{id}/artifacts`** returns `{artifacts: [{path, size, mtime}, …]}` for every regular file under the run's `artifacts/` (recursive, `[]` when none). **`GET /v1/runs/{id}/artifacts/{path}`** downloads one file as `application/octet-stream` with a `Content-Disposition` filename. All three are `404` on an unknown run id and `401` unauthenticated. To reach a run that is *still executing* — whose dir is only recorded on its record at finalize — the server injects a `resolveRunDir` resolver (`src/cli/commands/serve.ts`) that falls back to scanning the host runs root for the run id via `findRunDir` (`src/cli/shared/errors.ts`), which also resolves the **Docker-mode** host-side run dir (the run dir is a host mount in every sandbox mode). The HTTP layer grew a streaming path: `ServeResponse` now carries an optional `bodyBuffer` (binary bodies — NDJSON journal, artifact bytes) and a `stream(target)` hook driven by `src/cli/serve/server.ts`'s `makeStreamTarget`, which wires SSE writes to the socket and flips `aborted` (stopping the follow loop) the moment the client disconnects. **Security:** the journal is served **verbatim** — the credential redaction `RuntimeEventEmitter` already applies (values of `*_API_KEY` / `*_TOKEN` / `*_SECRET` env vars → `[REDACTED]`) *is* the redaction guarantee — and the raw per-step `%06d-*.out` / `.err` capture files are unreachable **by construction**: `listArtifacts` walks only the `artifacts/` subtree (the captures live in the run-dir root), and downloads go through `resolveArtifactPath`, which is traversal-proof via three guards checked before any read — reject empty/NUL requests, lexical containment (`resolve()` collapses `..`/absolute paths and the result must sit under `artifacts/`), and **symlink** containment (`realpathSync` on both the artifacts dir and the candidate — an `artifacts/` symlink pointing outside is rejected without reading its target, while one pointing inside still serves). Tests: `src/cli/serve/runfiles.test.ts` (artifact listing skips `.out`/`.err` captures; the full traversal battery — `../`, absolute, empty/NUL, directory, escaping symlink rejected without reading the target, inside-pointing symlink served, and a capture-file symlink rejected; SSE replays a terminal journal as `data:` frames then `event: end`, and stops promptly on client disconnect), `integration/serve-server.test.ts` (SSE mid-run: replayed `WORKFLOW_START`, a `STEP_END` **before** the run is terminal, `event: end` + socket close after completion, and the concatenated `data:` payloads equal the final journal line set; NDJSON on a terminal run byte-matches the journal; unknown run → `404`; unauthenticated → `401`; a credential echoed by a run is absent and `[REDACTED]` present in the stream; artifacts list-then-download round-trips byte-identically and a traversal is `404`), and `e2e/tests/147_serve_http_api.sh` (NDJSON byte-matches `run_summary.jsonl`; artifact list + byte-identical download; URL-encoded `%2e%2e` traversal → `404`). Docs: "Watch a run as it executes" and "Download a run's artifacts" sections in [Serve workflows over HTTP](serve.md) (with the raw-captures non-exposure called out as a security property), and the three endpoint rows in the `jaiph serve` table in [CLI](cli.md). (Deployability feedback `2026-07-23` — "a UI on top to be able to inspect what it is doing"; contract in `design/2026-07-23-serve-http-api.md` § Events streaming.)
- **Feat — `jaiph serve`: HTTP API with OpenAPI 3.1 + Swagger UI:** A new command turns a `.jh` file into a network-reachable, self-describing HTTP service, complementing `jaiph mcp` (which exposes the same workflows only over stdio JSON-RPC to a co-located parent). `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... ` (default `127.0.0.1:5247`) is dispatched from `src/cli/index.ts` and implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`, hand-rolled on `node:http` with **no runtime npm dependencies** (project policy — `package.json` `dependencies` stays absent; the MCP server set the precedent). Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (errors to stderr, exit `1`), `--env` resolved once via `resolveEnvPairs`, Docker config + image prepared once, credential pre-flight as warnings, and a sandbox-mode notice; all logs go to stderr and one startup line prints the listen URL and the `/docs` URL. Endpoints: `GET /` → `302 /docs`; unauthenticated `GET /healthz`, `GET /openapi.json`, `GET /docs`; and bearer-gated `GET /v1/workflows`, `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → terminal `200`), `GET /v1/runs`, `GET /v1/runs/{id}`, `POST /v1/runs/{id}/cancel`. A run is a durable resource `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` with `status ∈ running|succeeded|failed|cancelled`; **a workflow failure is not an HTTP error** — it comes back `200`/`202` with `status:"failed"` and the same failure narrative `jaiph mcp` returns. Errors use `{error:{code,message}}`: `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415 E_UNSUPPORTED_MEDIA_TYPE` (non-`application/json` body), `429 E_TOO_MANY_RUNS`. Workflow exposure, naming, descriptions, and request-body schemas come from the shared `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties:false`), with param validation mirroring MCP's `-32602` rules as HTTP `400`. **Auth:** bearer token from `JAIPH_SERVE_TOKEN` (env only, never argv), constant-time compared, required on all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only, a documented trade-off) — and binding a non-loopback `--host` without the token set is a startup error before any socket opens. **Limits:** `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs → `429`. `GET /openapi.json` returns OpenAPI **3.1.0** generated per request by the pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`) — one concrete path per workflow (own `operationId`, `#`-comment description, MCP input schema as request body), the run-resource paths, run/error component schemas, and a bearer `securityScheme`. `GET /docs` serves a static Swagger UI shell (`src/cli/serve/docs.ts`) loading `swagger-ui-dist` from a CDN with a **pinned exact version + SRI `integrity` hashes + `crossorigin`** on both assets and `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})` — no vendored assets, so `/docs` needs browser internet access and `/openapi.json` is the offline fallback (air-gap consequence recorded in the design doc). Execution reuses the MCP call layer, **moved** from `src/cli/mcp/call.ts` to `src/cli/exec/call.ts` (`McpCallResult` → `WorkflowCallResult`, caller now supplies `runId`, result extended with `{runDir?, exitStatus?, signal?}`); sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls. The generation/hot-reload machinery (`loadState`, watch/rewatch, generation dirs) was extracted from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts` and is now shared by both commands; editing a served source re-derives the workflow set and the OpenAPI document with no restart, and a superseded generation's scripts dir is deleted only after its in-flight runs finish (refcounted, since HTTP runs can outlive a reload). Shutdown: the first `SIGINT`/`SIGTERM` stops accepting and drains in-flight runs, a second signal cancels them, exit `0`. `jaiph mcp` behavior is unchanged after the extraction (its own tests prove it). Tests: `src/cli/serve/handler.test.ts` (injected-deps handler — unknown workflow `404`; missing / non-string / unexpected param `400`; cancel `202` → terminal `cancelled` with child + container teardown, cancel-on-terminal `409`; `429`/`413`/`415`; auth matrix incl. non-loopback-without-token exit `1`), `src/cli/serve/openapi.test.ts` (output passes a real OpenAPI 3.1 validator devDependency; one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture), `src/cli/serve/docs.test.ts` (pinned version + `integrity` + `crossorigin` on both assets), `integration/serve-server.test.ts` (real server on port 0 — `wait=true` round-trips a `return` value into `result_text` with `status:"succeeded"`; async `202` + `Location` polled to the same terminal result; failing workflow → HTTP `200` with `status:"failed"`, `exit_status`, and `run dir:` in `result_text`; hot-reload surfaces a new workflow in `/openapi.json` and `/v1/workflows` while a pre-reload run still completes), and `e2e/tests/147_serve_http_api.sh` (wired into `e2e/test_all.sh`). Docs: new [Serve workflows over HTTP](serve.md) how-to, a `jaiph serve` section in [CLI](cli.md), `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` rows in [Environment variables](env-vars.md), `printUsage` in `src/cli/shared/usage.ts`, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23`; contract in `design/2026-07-23-serve-http-api.md`.)
diff --git a/QUEUE.md b/QUEUE.md
index 92226661..8c7d967a 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,41 +14,6 @@ Process rules:
***
-## Feat: OTLP trace export — one span tree per run, zero dependencies #dev-ready
-
-**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable would be the easiest way" to make jaiph observable in a company setting. This task is the OTEL half.
-
-**Problem:** A jaiph run already produces a complete, credential-redacted, hash-chained event timeline (`run_summary.jsonl`, written by `RuntimeEventEmitter`, `src/runtime/kernel/runtime-event-emitter.ts`), but it is invisible to standard observability stacks (Grafana/Tempo, Honeycomb, Datadog, any OTLP collector). Operators running workflows in CI or as a service have no traces, no latency breakdown per step/prompt, and no failure signal outside the local run dir.
-
-**Required behavior:**
-
-* **Architecture decision (pinned):** export happens **host-side, after the run completes, by reading the run's `run_summary.jsonl`** — not inside the runtime/emitter. Rationale: the journal is complete (the live stderr stream lacks `WORKFLOW_*` events), already redacted, and host-visible in every sandbox mode (the run dir is a host mount), so nothing new crosses the container boundary and no `OTEL_*` env forwarding into the sandbox is needed. Runs are minutes-long; end-of-run batching is the normal OTLP pattern anyway.
-* New module `src/cli/telemetry/otlp.ts`, zero runtime dependencies (`node:https`/`node:http` request only):
- * a **pure** function `runSummaryToOtlp(lines, meta)` mapping journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`;
- * a poster with a 10 s timeout.
-* Mapping contract:
- * `traceId` = the run id UUID with dashes stripped (32 hex chars — OTLP/JSON encodes trace/span ids as hex per the spec's JSON mapping); `spanId` = first 16 hex chars of `sha256()`. Deterministic: re-exporting a run yields identical ids.
- * Root span per run: name `workflow `, from `WORKFLOW_START`/`WORKFLOW_END` timestamps (fallback: first/last event `ts`); status `ERROR` (code 2) when `exitStatus !== 0` or a signal terminated the run, else `OK`.
- * One span per `STEP_START`/`STEP_END` pair (matched by event `id`), parented via the event's `parent_id` (root when null); `kind: SPAN_KIND_INTERNAL`; attributes: `jaiph.step.kind`, `jaiph.step.func`, `jaiph.step.name`, `jaiph.step.seq`, `jaiph.step.depth`, `jaiph.step.status`, `jaiph.step.elapsed_ms`; span status ERROR when the step status is nonzero.
- * `PROMPT_START`/`PROMPT_END` pairs become child spans of their `step_id` with `jaiph.prompt.backend`, `jaiph.prompt.model`, `jaiph.prompt.status`.
- * `LOGERR`/`LOGWARN` become span events on the root span. `ts` (ISO) → `timeUnixNano` as strings. A `STEP_START` with no matching `STEP_END` (crash) closes at the last event timestamp with status ERROR.
- * Resource: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), pairs from `OTEL_RESOURCE_ATTRIBUTES`, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`.
-* Enablement & endpoint (standard OTEL env, no new `JAIPH_*` unless genuinely needed — any that is added must get its `docs/env-vars.md` row, the parity lint enforces this): enabled iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set. `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) applied. Only `http/json` is spoken: if `OTEL_EXPORTER_OTLP_PROTOCOL` is set to anything other than `http/json`, warn on stderr and skip export (respect the operator's explicit intent rather than mis-speak a protocol).
-* Hook point: a single shared post-run function (e.g. `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})`) invoked wherever a run reaches terminal state on the host — `jaiph run` completion and the shared workflow-call layer used by MCP tool calls (`src/cli/mcp/call.ts` or its current location). One choke point, all modes covered (host, Docker snapshot, inplace).
-* **Failure semantics: telemetry is never load-bearing.** Unreachable/erroring collector → exactly one stderr warning line; the run's exit code, output, and journal are untouched. No retries, no queue.
-* Docs: `docs/observability.md` how-to (enabling against a local `otel-collector`, one hosted-backend example, span-tree screenshot-level description of what maps to what); `docs/env-vars.md` gets a "Telemetry variables" section listing the consumed `OTEL_*` names (the page already covers non-`JAIPH_*` vendor variables); README bullet.
-
-Acceptance:
-
-* Unit tests on `runSummaryToOtlp` with fixture journals: trace id derived from run id; step span parented per `parent_id`; prompt span is a child of its `step_id` with backend/model attributes; failed step → span status 2; nonzero run exit → root status 2; ISO `ts` → correct `timeUnixNano` strings; unmatched `STEP_START` closes with ERROR at last event time; deterministic ids across two invocations.
-* Unit tests: endpoint resolution (traces-specific verbatim vs generic + `/v1/traces`; traces-specific wins when both set), header parsing, `http/json`-only protocol guard (warn + skip on `grpc`).
-* Integration test: a local fake-collector HTTP server receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set; the same run with no OTEL env sends nothing; with the collector returning 500 (and separately: connection refused), the run's exit code is 0 and exactly one warning line appears on stderr.
-* Integration test: an MCP `tools/call` (or shared-call-layer invocation) also triggers exactly one export per call.
-* Redaction test: a credential value present in step output appears in the exported payload only as `[REDACTED]` (the export reads the journal, never raw captures).
-* `package.json` `dependencies` remains absent/empty; `npm test` passes.
-
-***
-
## Feat: Sentry error reporting on failed runs #dev-ready
**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable". This task is the Sentry half: failed workflow runs become Sentry error events so operators get alerting/grouping without scraping run dirs.
diff --git a/README.md b/README.md
index 013a88ae..8053efe0 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md)
-> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
+> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md), [Export traces to an OTLP collector](docs/observability.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
---
@@ -28,6 +28,7 @@
- **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/` ([Use & publish a library](docs/libraries.md)), and optional `hooks.json` ([CLI](docs/cli.md), [Add a hook](docs/hooks.md)).
- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)).
- **HTTP API** — `jaiph serve ./tools.jh` serves the same workflows over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs ([Serve workflows over HTTP](docs/serve.md)).
+- **OpenTelemetry** — set the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and each run exports one span tree (workflow → steps → prompts) to any OTLP collector — Grafana Tempo, Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, zero new dependencies, never load-bearing ([Export traces to an OTLP collector](docs/observability.md)).
## Core components
diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html
index 3db3c43b..01546c26 100644
--- a/docs/_layouts/docs.html
+++ b/docs/_layouts/docs.html
@@ -56,6 +56,7 @@
diff --git a/docs/env-vars.md b/docs/env-vars.md
index 5aebaae7..a9e19ecd 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -117,6 +117,24 @@ An `--env`-forwarded variable is visible to trusted `run` script/workflow steps
For the common "forward this host key" case, the [`trusted_envs`](configuration.md#trusted-envs) config key is the declarative in-file alternative to `--env`: a `.jh` file names the host keys its trusted `run` steps require (`trusted_envs = "GITHUB_TOKEN"`), they resolve from a pristine host-env snapshot, and the same reserved-key (`E_ENV_RESERVED`) and missing-value (`E_ENV_MISSING`) rules apply. An explicit `--env KEY=VALUE` still overrides the snapshot value for that key. Like `--env`, `trusted_envs` values reach trusted `run` steps only — never `prompt` subprocesses.
+## Telemetry variables
+
+Jaiph reads the standard OpenTelemetry environment variables to export one trace
+per run to an OTLP collector — there are **no `JAIPH_*` variables** for this
+feature. These are consumed **host-side, after the run completes** (they are never
+forwarded into the Docker sandbox), so they are not tracked by the `JAIPH_*`
+source-parity harness above. Export is enabled iff a traces endpoint is set. See
+[Export traces to an OTLP collector](observability.md).
+
+| Variable | Type | Default | Role |
+|---|---|---|---|
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | string (URL) | — | Traces endpoint, used verbatim. Enables export. Wins over the generic endpoint when both are set. |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | string (URL) | — | Generic OTLP base URL; `/v1/traces` is appended. Enables export when the traces-specific one is unset. |
+| `OTEL_EXPORTER_OTLP_HEADERS` | string (`k=v,k=v`) | — | Comma-separated headers applied to the export POST (for example an auth token). |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | string (`http/json`) | `http/json` | Only `http/json` is spoken. Any other value (for example `grpc`) → warn on stderr and skip export. |
+| `OTEL_SERVICE_NAME` | string | `jaiph` | `service.name` resource attribute on every exported span. |
+| `OTEL_RESOURCE_ATTRIBUTES` | string (`k=v,k=v`) | — | Extra resource attributes. Jaiph also always adds `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, and `jaiph.source`. |
+
## Installer and `jaiph use`
These variables are consumed by `docs/install` (the installer shell script) and by `jaiph use` when it re-invokes the installer. They are **not** read from inside the Jaiph TypeScript source.
diff --git a/docs/index.html b/docs/index.html
index b0aaa6a2..e6c86ff5 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -569,6 +569,11 @@
Runtime
rel="noopener noreferrer">OpenAPI 3.1 document and a browser Swagger UI at /docs,
so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs. See
Serve workflows over HTTP.
+
OpenTelemetry. Set the standard
+ OTEL_EXPORTER_OTLP_ENDPOINT and every run exports one span tree
+ (workflow → steps → prompts) to any OTLP collector — Grafana Tempo,
+ Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, with zero new dependencies, and never
+ load-bearing. See Export traces to an OTLP collector.
Samples
Jaiph source code is built mostly with real Jaiph workflows. The
diff --git a/docs/observability.md b/docs/observability.md
new file mode 100644
index 00000000..411af726
--- /dev/null
+++ b/docs/observability.md
@@ -0,0 +1,103 @@
+---
+title: Export traces to an OTLP collector
+permalink: /how-to/observability
+diataxis: how-to
+---
+
+# Export traces to an OTLP collector
+
+Every Jaiph run already produces a complete, credential-redacted event timeline
+(`run_summary.jsonl` — see [Architecture](architecture.md#durable-artifact-layout)).
+This recipe turns that timeline into an **OpenTelemetry trace**: one span tree per
+run, exported over OTLP/HTTP (JSON) to any collector — a local
+[`otel-collector`](https://opentelemetry.io/docs/collector/), Grafana Tempo,
+Honeycomb, Datadog, or anything else that speaks OTLP.
+
+Export is **host-side and end-of-run**: after a run reaches its terminal state the
+CLI reads that run's `run_summary.jsonl` and posts one trace. Nothing new crosses
+the sandbox boundary, and no `OTEL_*` variable is forwarded into the container —
+the run directory is a host mount, so the host already sees the finished journal.
+
+## Enable it
+
+Export is off until you point Jaiph at a collector with the standard OpenTelemetry
+environment variables — there are **no `JAIPH_*` variables** for this feature. Set
+either the traces-specific endpoint (used verbatim) or the generic base endpoint
+(`/v1/traces` is appended):
+
+```bash
+# Generic base — Jaiph appends /v1/traces
+export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
+
+# …or the traces-specific endpoint, used exactly as given (wins if both are set)
+export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4318/v1/traces"
+
+jaiph run ./flows/review.jh "review this diff"
+```
+
+Every `jaiph run` completion and every workflow invoked through `jaiph mcp` /
+`jaiph serve` then posts exactly one trace.
+
+### A local collector
+
+Run a collector that logs what it receives, then run a workflow against it:
+
+```bash
+docker run --rm -p 4318:4318 otel/opentelemetry-collector:latest
+OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" jaiph run ./hello.jh
+```
+
+### A hosted backend (Honeycomb)
+
+Hosted backends want the traces endpoint plus an auth header. Headers are a
+comma-separated `key=value` list:
+
+```bash
+export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://api.honeycomb.io/v1/traces"
+export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY"
+export OTEL_SERVICE_NAME="jaiph-ci"
+jaiph run ./deploy.jh
+```
+
+Set `OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,team=platform"` to tag
+every span with extra resource attributes.
+
+## What maps to what
+
+One run becomes one trace. The **trace id** is the run's UUID with dashes stripped;
+re-exporting the same run produces byte-identical ids, so a retry never forks the
+trace.
+
+- **Root span** `workflow ` — spans the whole run (`WORKFLOW_START` →
+ `WORKFLOW_END`). Its status is **ERROR** when the run exits nonzero or a signal
+ terminated it, otherwise **OK**. Each `logerr` / `logwarn` becomes a span event
+ on this root.
+- **Step spans** — one per step, nested by the run tree (a step's parent is the
+ step that invoked it; top-level steps hang off the root). Attributes:
+ `jaiph.step.kind` (`workflow` / `rule` / `script` / `prompt`), `jaiph.step.func`,
+ `jaiph.step.name`, `jaiph.step.seq`, `jaiph.step.depth`, `jaiph.step.status`,
+ `jaiph.step.elapsed_ms`, plus the redacted `jaiph.step.out` / `jaiph.step.err`
+ captures. A nonzero step status is an ERROR span; a step with no end (a crash)
+ closes at the last event's time as ERROR.
+- **Prompt spans** — child of the step that issued the prompt, with
+ `jaiph.prompt.backend`, `jaiph.prompt.model`, and `jaiph.prompt.status`.
+
+The exported payload is sourced entirely from `run_summary.jsonl`, which is already
+credential-redacted, so secrets in step output arrive as `[REDACTED]`. The raw
+per-step capture files are never read.
+
+## Failure is never load-bearing
+
+Telemetry never affects a run. An unreachable or erroring collector produces
+**exactly one** warning line on stderr; the run's exit code, output, and journal
+are untouched. There are no retries and no queue — a run is minutes long, so
+end-of-run batching is the normal OTLP pattern.
+
+Only OTLP/HTTP with a JSON payload is spoken. If `OTEL_EXPORTER_OTLP_PROTOCOL` is
+set to anything other than `http/json` (for example `grpc`), Jaiph warns and skips
+the export rather than mis-speak a protocol.
+
+## Related
+
+- [Environment variables — Telemetry variables](env-vars.md#telemetry-variables) — the full list of consumed `OTEL_*` names.
+- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the `run_summary.jsonl` timeline the export reads.
diff --git a/integration/otlp-export.test.ts b/integration/otlp-export.test.ts
new file mode 100644
index 00000000..1d054968
--- /dev/null
+++ b/integration/otlp-export.test.ts
@@ -0,0 +1,327 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { createServer, type Server } from "node:http";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { AddressInfo } from "node:net";
+import { dirname, join } from "node:path";
+
+const CLI_PATH = join(process.cwd(), "dist/src/cli.js");
+
+interface CapturedRequest {
+ method: string;
+ url: string;
+ headers: Record;
+ body: string;
+}
+
+interface FakeCollector {
+ port: number;
+ requests: CapturedRequest[];
+ close: () => Promise;
+}
+
+/** A minimal OTLP collector that records every request and replies `status`. */
+function startCollector(status = 200): Promise {
+ return new Promise((resolve) => {
+ const requests: CapturedRequest[] = [];
+ const server: Server = createServer((req, res) => {
+ let body = "";
+ req.on("data", (c) => (body += c));
+ req.on("end", () => {
+ requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
+ res.writeHead(status);
+ res.end();
+ });
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ resolve({
+ port,
+ requests,
+ close: () => new Promise((r) => server.close(() => r())),
+ });
+ });
+ });
+}
+
+/** Bind then immediately release a port so a request to it is refused. */
+function reservedClosedPort(): Promise {
+ return new Promise((resolve) => {
+ const server = createServer();
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+/**
+ * Run the CLI asynchronously so the test process's event loop keeps running —
+ * the in-process fake collector must be able to accept the export connection
+ * while the run is in flight (spawnSync would block the loop and deadlock it).
+ */
+function runCli(
+ args: string[],
+ opts: { cwd: string; env: NodeJS.ProcessEnv },
+): Promise<{ status: number; stdout: string; stderr: string }> {
+ return new Promise((resolve) => {
+ const child = spawn("node", [CLI_PATH, ...args], { cwd: opts.cwd, env: opts.env, stdio: ["ignore", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (c: string) => (stdout += c));
+ child.stderr.on("data", (c: string) => (stderr += c));
+ child.on("close", (code) => resolve({ status: code ?? 1, stdout, stderr }));
+ });
+}
+
+function baseEnv(runsRoot: string): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
+ PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
+ };
+}
+
+const STEP_FIXTURE = [
+ 'script emit = `echo "hello"`',
+ "workflow default() {",
+ ' log "a log line"',
+ ' logerr "an error line"',
+ " run emit()",
+ ' return "done"',
+ "}",
+ "",
+].join("\n");
+
+function otlpWarnings(stderr: string): string[] {
+ return stderr.split("\n").filter((l) => l.includes("OTLP trace export"));
+}
+
+test("jaiph run: with OTLP env, exactly one well-formed POST reaches /v1/traces", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-run-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, STEP_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collector.port}`,
+ OTEL_EXPORTER_OTLP_HEADERS: "x-test=abc",
+ OTEL_SERVICE_NAME: "jaiph-it",
+ },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(collector.requests.length, 1, "exactly one export POST");
+ const req = collector.requests[0];
+ assert.equal(req.method, "POST");
+ assert.equal(req.url, "/v1/traces", "generic endpoint gets /v1/traces appended");
+ assert.equal(req.headers["content-type"], "application/json");
+ assert.equal(req.headers["x-test"], "abc", "OTEL_EXPORTER_OTLP_HEADERS applied");
+
+ const payload = JSON.parse(req.body) as {
+ resourceSpans: Array<{
+ resource: { attributes: Array<{ key: string; value: { stringValue?: string } }> };
+ scopeSpans: Array<{ spans: Array<{ name: string; traceId: string }> }>;
+ }>;
+ };
+ const rs = payload.resourceSpans[0];
+ const spans = rs.scopeSpans[0].spans;
+ assert.ok(spans.some((s) => s.name === "workflow default"), "root span present");
+ assert.ok(spans.some((s) => s.name === "script emit"), "step span present");
+ assert.ok(spans.every((s) => /^[0-9a-f]{32}$/.test(s.traceId)), "trace id is 32 hex chars");
+ const svc = rs.resource.attributes.find((a) => a.key === "service.name");
+ assert.equal(svc?.value.stringValue, "jaiph-it", "OTEL_SERVICE_NAME used");
+ } finally {
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: with no OTLP env, nothing is exported", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-none-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, STEP_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: baseEnv(join(root, ".jaiph/runs")),
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(collector.requests.length, 0, "no OTEL env → no export");
+ assert.equal(otlpWarnings(result.stderr).length, 0);
+ } finally {
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: a collector 500 keeps exit 0 and prints exactly one warning", async () => {
+ const collector = await startCollector(500);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-500-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, STEP_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `http://127.0.0.1:${collector.port}/v1/traces`,
+ },
+ });
+ assert.equal(result.status, 0, "telemetry is never load-bearing");
+ assert.equal(collector.requests.length, 1, "one POST was attempted");
+ const warns = otlpWarnings(result.stderr);
+ assert.equal(warns.length, 1, `exactly one warning line, got: ${result.stderr}`);
+ assert.match(warns[0], /HTTP 500/);
+ } finally {
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: a refused collector keeps exit 0 and prints exactly one warning", async () => {
+ const port = await reservedClosedPort();
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-refused-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, STEP_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `http://127.0.0.1:${port}/v1/traces`,
+ },
+ });
+ assert.equal(result.status, 0);
+ const warns = otlpWarnings(result.stderr);
+ assert.equal(warns.length, 1, `exactly one warning line, got: ${result.stderr}`);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("redaction: a credential in step output reaches the payload only as [REDACTED]", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-redact-"));
+ const SECRET = "supersecretvalue";
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(
+ jh,
+ ['script leak = `printf %s "$SECRET_API_KEY"`', "workflow default() {", " run leak()", "}", ""].join("\n"),
+ );
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ SECRET_API_KEY: SECRET,
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collector.port}`,
+ },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(collector.requests.length, 1);
+ const body = collector.requests[0].body;
+ assert.equal(body.includes(SECRET), false, "raw credential must never appear in the payload");
+ assert.ok(body.includes("[REDACTED]"), "the redacted marker flows through from the journal");
+ } finally {
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// --- MCP shared-call-layer export ------------------------------------------
+
+interface McpMessage {
+ id?: number;
+ method?: string;
+ result?: unknown;
+ [k: string]: unknown;
+}
+
+test("MCP tools/call triggers exactly one export per call via the shared call layer", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-mcp-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, ["# Greets.", "workflow greet(name) {", ' return "hi ${name}"', "}", ""].join("\n"));
+ const child: ChildProcessWithoutNullStreams = spawn("node", [CLI_PATH, "mcp", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collector.port}`,
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ }) as ChildProcessWithoutNullStreams;
+
+ const messages: McpMessage[] = [];
+ let buf = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ buf += chunk;
+ let idx = buf.indexOf("\n");
+ while (idx !== -1) {
+ const line = buf.slice(0, idx);
+ buf = buf.slice(idx + 1);
+ if (line.length > 0) messages.push(JSON.parse(line) as McpMessage);
+ idx = buf.indexOf("\n");
+ }
+ });
+
+ const send = (m: Record): void => {
+ child.stdin.write(`${JSON.stringify(m)}\n`);
+ };
+ const waitFor = (pred: (m: McpMessage) => boolean, label: string): Promise =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`timeout: ${label}`)), 20_000);
+ const tick = (): void => {
+ const found = messages.find(pred);
+ if (found) {
+ clearTimeout(timer);
+ resolve(found);
+ return;
+ }
+ setTimeout(tick, 50);
+ };
+ tick();
+ });
+
+ try {
+ send({
+ jsonrpc: "2.0",
+ id: 0,
+ method: "initialize",
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } },
+ });
+ await waitFor((m) => m.id === 0, "initialize");
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
+
+ send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "greet", arguments: { name: "x" } } });
+ const call = await waitFor((m) => m.id === 1, "tools/call");
+ assert.equal((call.result as { isError: boolean }).isError, false);
+
+ // The export is awaited inside the shared call layer before the response is
+ // sent, so by now exactly one POST has landed for this one call.
+ assert.equal(collector.requests.length, 1, "exactly one export per tools/call");
+ assert.equal(collector.requests[0].url, "/v1/traces");
+ const payload = JSON.parse(collector.requests[0].body) as {
+ resourceSpans: Array<{ resource: { attributes: Array<{ key: string; value: { stringValue?: string } }> } }>;
+ };
+ const wf = payload.resourceSpans[0].resource.attributes.find((a) => a.key === "jaiph.workflow");
+ assert.equal(wf?.value.stringValue, "greet", "the tool's workflow symbol is on the resource");
+ } finally {
+ await new Promise((resolve) => {
+ child.on("exit", () => resolve());
+ child.stdin.end();
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
+ });
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index 6206846a..ecdb1eee 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -71,6 +71,7 @@ import { preflightAgentCredentials, collectEntryBackends } from "../run/prefligh
import { planTrustedEnvs } from "../run/trusted-envs";
import { colorize, formatJaiphRunningBannerLines } from "../run/display";
import { createRunEmitter } from "../run/emitter";
+import { exportRunTelemetry } from "../telemetry/otlp";
import {
createStderrParser,
createRunState,
@@ -333,8 +334,8 @@ export async function runWorkflow(rest: string[]): Promise {
ttyCtx.nonTTYHeartbeatInterval = undefined;
}
- return reportResult(
- runState.capturedStderr, childExit.status, startedAt, runtimeEnv,
+ return await reportResult(
+ runState.capturedStderr, childExit.status, childExit.signal, startedAt, runtimeEnv,
emitter, runState.workflowRunId, inputAbs, workspaceRoot, metaFile,
dockerResult?.sandboxRunDir, runId,
);
@@ -554,9 +555,10 @@ function writePlainStdout(chunk: string, ttyCtx: TTYContext): void {
redrawTTYBottomLine(ttyCtx);
}
-function reportResult(
+async function reportResult(
capturedStderr: string,
exitStatus: number,
+ signal: NodeJS.Signals | null,
startedAt: number,
runtimeEnv: Record,
emitter: ReturnType,
@@ -566,7 +568,7 @@ function reportResult(
metaFile: string,
sandboxRunDir?: string,
expectedRunId?: string,
-): number {
+): Promise {
const elapsedMs = Date.now() - startedAt;
const elapsedLabel = formatElapsedDuration(elapsedMs);
let runDir: string | undefined;
@@ -592,6 +594,9 @@ function reportResult(
runDir = discovered.runDir;
summaryFile = discovered.summaryFile;
}
+ // Export a trace to an OTLP collector when configured (standard OTEL env).
+ // Best-effort: never affects the exit code, output, or journal below.
+ await exportRunTelemetry({ runDir, workflow: "default", exitStatus, signal, env: process.env });
const runtimeDebugEnabled = runtimeEnv.JAIPH_DEBUG === "true";
const runtimeErrorPrinted = sandboxRunDir
? false
diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts
index f712912c..e81775c2 100644
--- a/src/cli/exec/call.ts
+++ b/src/cli/exec/call.ts
@@ -16,6 +16,7 @@ import {
type DockerRunConfig,
} from "../../runtime/docker";
import { discoverDockerRunDir, remapContainerPath } from "../shared/errors";
+import { exportRunTelemetry } from "../telemetry/otlp";
/**
* Result of executing one workflow call. `text` is the same content an MCP
@@ -109,10 +110,19 @@ export async function callWorkflow(
runtimeEnv.JAIPH_RUN_ID = runId;
runtimeEnv.JAIPH_SCRIPTS = env.scriptsDir;
- if (dockerConfig.enabled) {
- return callWorkflowDocker(env, dockerConfig, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx);
- }
- return callWorkflowHost(env, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx);
+ const result = dockerConfig.enabled
+ ? await callWorkflowDocker(env, dockerConfig, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx)
+ : await callWorkflowHost(env, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx);
+ // One export per call — the shared choke point covering every MCP tool call and
+ // HTTP `jaiph serve` invocation. Best-effort; never changes the call result.
+ await exportRunTelemetry({
+ runDir: result.runDir,
+ workflow: workflowSymbol,
+ exitStatus: result.exitStatus ?? 0,
+ signal: result.signal ?? null,
+ env: process.env,
+ });
+ return result;
}
/** Host execution — same self-spawn path as `jaiph run --raw`. */
diff --git a/src/cli/telemetry/otlp.test.ts b/src/cli/telemetry/otlp.test.ts
new file mode 100644
index 00000000..502b446d
--- /dev/null
+++ b/src/cli/telemetry/otlp.test.ts
@@ -0,0 +1,266 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import {
+ runSummaryToOtlp,
+ resolveOtlpEndpoint,
+ parseKeyValueList,
+ exportRunTelemetry,
+ type OtlpMeta,
+} from "./otlp";
+
+const RUN_ID = "11111111-2222-3333-4444-555555555555";
+
+/** Mirror of the module's span-id derivation, for asserting parent links. */
+function spanIdFor(eventId: string): string {
+ return createHash("sha256").update(eventId, "utf8").digest("hex").slice(0, 16);
+}
+
+function nano(ts: string): string {
+ return (BigInt(Date.parse(ts)) * 1_000_000n).toString();
+}
+
+const T = {
+ wfStart: "2026-04-21T16:02:00Z",
+ fibStart: "2026-04-21T16:02:01Z",
+ fibEnd: "2026-04-21T16:02:02Z",
+ promptStart: "2026-04-21T16:02:03Z",
+ promptEnd: "2026-04-21T16:02:05Z",
+ boomStart: "2026-04-21T16:02:06Z",
+ boomEnd: "2026-04-21T16:02:07Z",
+ hangStart: "2026-04-21T16:02:08Z",
+ log: "2026-04-21T16:02:09Z",
+ wfEnd: "2026-04-21T16:02:10Z",
+};
+
+/** A journal covering the root, a script step, a prompt, a failed step, and a crash. */
+function fixtureLines(): string[] {
+ const runId = RUN_ID;
+ const events: Record[] = [
+ { type: "WORKFLOW_START", workflow: "default", source: "fib.jh", ts: T.wfStart, run_id: runId },
+ { type: "STEP_START", func: "default", kind: "workflow", name: "default", ts: T.wfStart, id: "R:1", parent_id: null, seq: 1, depth: 0, run_id: runId },
+ { type: "STEP_START", func: "fib", kind: "script", name: "fib", ts: T.fibStart, id: "R:2", parent_id: "R:1", seq: 2, depth: 1, run_id: runId },
+ { type: "STEP_END", func: "fib", kind: "script", name: "fib", ts: T.fibEnd, status: 0, elapsed_ms: 100, id: "R:2", parent_id: "R:1", seq: 2, depth: 1, run_id: runId, out_content: "ok\n", err_content: "" },
+ { type: "STEP_START", func: "prompt", kind: "prompt", name: "claude", ts: T.promptStart, id: "R:p1", parent_id: "R:1", seq: 3, depth: 1, run_id: runId },
+ { type: "PROMPT_START", ts: T.promptStart, run_id: runId, step_id: "R:p1", backend: "claude", model: "sonnet", status: null },
+ { type: "PROMPT_END", ts: T.promptEnd, run_id: runId, step_id: "R:p1", backend: "claude", model: "sonnet", status: 0 },
+ { type: "STEP_END", func: "prompt", kind: "prompt", name: "claude", ts: T.promptEnd, status: 0, elapsed_ms: 2000, id: "R:p1", parent_id: "R:1", seq: 3, depth: 1, run_id: runId, out_content: "", err_content: "" },
+ { type: "STEP_START", func: "boom", kind: "script", name: "boom", ts: T.boomStart, id: "R:3", parent_id: "R:1", seq: 4, depth: 1, run_id: runId },
+ { type: "STEP_END", func: "boom", kind: "script", name: "boom", ts: T.boomEnd, status: 1, elapsed_ms: 5, id: "R:3", parent_id: "R:1", seq: 4, depth: 1, run_id: runId, out_content: "", err_content: "bad\n" },
+ { type: "STEP_START", func: "hang", kind: "script", name: "hang", ts: T.hangStart, id: "R:4", parent_id: "R:1", seq: 5, depth: 1, run_id: runId },
+ { type: "LOGERR", message: "warn-line", depth: 1, ts: T.log, run_id: runId },
+ { type: "WORKFLOW_END", workflow: "default", source: "fib.jh", ts: T.wfEnd, run_id: runId },
+ ];
+ return events.map((e) => JSON.stringify(e));
+}
+
+const META: OtlpMeta = {
+ workflow: "default",
+ exitStatus: 1,
+ signal: null,
+ serviceName: "jaiph",
+ resourceAttributes: { "jaiph.version": "9.9.9", "deployment.environment": "ci" },
+};
+
+interface Span {
+ traceId: string;
+ spanId: string;
+ parentSpanId?: string;
+ name: string;
+ kind: number;
+ startTimeUnixNano: string;
+ endTimeUnixNano: string;
+ status: { code: number; message?: string };
+ attributes?: Array<{ key: string; value: { stringValue?: string; intValue?: string } }>;
+ events?: Array<{ timeUnixNano: string; name: string; attributes: Array<{ key: string; value: { stringValue: string } }> }>;
+}
+
+function spansOf(payload: Record): Span[] {
+ const rs = (payload.resourceSpans as Array>)[0];
+ const ss = (rs.scopeSpans as Array>)[0];
+ return ss.spans as unknown as Span[];
+}
+
+function attrString(span: Span, key: string): string | undefined {
+ return span.attributes?.find((a) => a.key === key)?.value.stringValue;
+}
+function attrInt(span: Span, key: string): string | undefined {
+ return span.attributes?.find((a) => a.key === key)?.value.intValue;
+}
+
+test("runSummaryToOtlp: trace id is the run id UUID with dashes stripped", () => {
+ const spans = spansOf(runSummaryToOtlp(fixtureLines(), META));
+ for (const s of spans) {
+ assert.equal(s.traceId, "11111111222233334444555555555555");
+ assert.equal(s.traceId.length, 32);
+ }
+});
+
+test("runSummaryToOtlp: root span carries workflow name and OK/ERROR from run exit", () => {
+ const root = spansOf(runSummaryToOtlp(fixtureLines(), META)).find((s) => s.name === "workflow default")!;
+ assert.ok(root, "root span present");
+ assert.equal(root.parentSpanId, undefined, "root has no parent");
+ assert.equal(root.spanId, spanIdFor(RUN_ID));
+ assert.equal(root.startTimeUnixNano, nano(T.wfStart));
+ assert.equal(root.endTimeUnixNano, nano(T.wfEnd));
+ // nonzero run exit → root status 2
+ assert.equal(root.status.code, 2);
+
+ const ok = spansOf(runSummaryToOtlp(fixtureLines(), { ...META, exitStatus: 0 })).find((s) => s.name === "workflow default")!;
+ assert.equal(ok.status.code, 1);
+
+ const sig = spansOf(runSummaryToOtlp(fixtureLines(), { ...META, exitStatus: 0, signal: "SIGKILL" })).find((s) => s.name === "workflow default")!;
+ assert.equal(sig.status.code, 2, "a terminating signal marks the root ERROR even at exit 0");
+});
+
+test("runSummaryToOtlp: step span parented per parent_id (root when null)", () => {
+ const spans = spansOf(runSummaryToOtlp(fixtureLines(), META));
+ const rootStep = spans.find((s) => s.spanId === spanIdFor("R:1"))!;
+ assert.equal(rootStep.parentSpanId, spanIdFor(RUN_ID), "parent_id null → parented to the run root span");
+ const fib = spans.find((s) => s.spanId === spanIdFor("R:2"))!;
+ assert.equal(fib.parentSpanId, spanIdFor("R:1"), "fib parented to the workflow step via parent_id");
+ assert.equal(fib.name, "script fib");
+ assert.equal(attrString(fib, "jaiph.step.kind"), "script");
+ assert.equal(attrString(fib, "jaiph.step.func"), "fib");
+ assert.equal(attrString(fib, "jaiph.step.name"), "fib");
+ assert.equal(attrInt(fib, "jaiph.step.seq"), "2");
+ assert.equal(attrInt(fib, "jaiph.step.depth"), "1");
+ assert.equal(attrInt(fib, "jaiph.step.status"), "0");
+ assert.equal(attrInt(fib, "jaiph.step.elapsed_ms"), "100");
+ assert.equal(attrString(fib, "jaiph.step.out"), "ok\n");
+ assert.equal(fib.startTimeUnixNano, nano(T.fibStart));
+ assert.equal(fib.endTimeUnixNano, nano(T.fibEnd));
+});
+
+test("runSummaryToOtlp: failed step → span status 2", () => {
+ const boom = spansOf(runSummaryToOtlp(fixtureLines(), META)).find((s) => s.spanId === spanIdFor("R:3"))!;
+ assert.equal(boom.status.code, 2);
+ assert.equal(attrInt(boom, "jaiph.step.status"), "1");
+ assert.equal(attrString(boom, "jaiph.step.err"), "bad\n");
+});
+
+test("runSummaryToOtlp: prompt span is a child of its step_id with backend/model attributes", () => {
+ const spans = spansOf(runSummaryToOtlp(fixtureLines(), META));
+ const prompt = spans.find((s) => s.name === "prompt claude" && s.attributes?.some((a) => a.key === "jaiph.prompt.backend"))!;
+ assert.ok(prompt, "prompt span present");
+ assert.equal(prompt.parentSpanId, spanIdFor("R:p1"), "prompt parented to its step_id");
+ assert.equal(attrString(prompt, "jaiph.prompt.backend"), "claude");
+ assert.equal(attrString(prompt, "jaiph.prompt.model"), "sonnet");
+ assert.equal(attrInt(prompt, "jaiph.prompt.status"), "0");
+ assert.equal(prompt.status.code, 1);
+});
+
+test("runSummaryToOtlp: unmatched STEP_START closes with ERROR at the last event time", () => {
+ const hang = spansOf(runSummaryToOtlp(fixtureLines(), META)).find((s) => s.spanId === spanIdFor("R:4"))!;
+ assert.equal(hang.status.code, 2, "a crashed (no STEP_END) step is ERROR");
+ assert.equal(hang.startTimeUnixNano, nano(T.hangStart));
+ assert.equal(hang.endTimeUnixNano, nano(T.wfEnd), "closes at the last event timestamp");
+ // No STEP_END → no status/elapsed attributes.
+ assert.equal(attrInt(hang, "jaiph.step.status"), undefined);
+});
+
+test("runSummaryToOtlp: LOGERR becomes a span event on the root span", () => {
+ const root = spansOf(runSummaryToOtlp(fixtureLines(), META)).find((s) => s.name === "workflow default")!;
+ assert.equal(root.events?.length, 1);
+ const ev = root.events![0];
+ assert.equal(ev.timeUnixNano, nano(T.log));
+ assert.equal(ev.attributes.find((a) => a.key === "level")?.value.stringValue, "LOGERR");
+ assert.equal(ev.attributes.find((a) => a.key === "message")?.value.stringValue, "warn-line");
+});
+
+test("runSummaryToOtlp: resource carries service.name, OTEL pairs, and jaiph.* attributes", () => {
+ const payload = runSummaryToOtlp(fixtureLines(), META);
+ const rs = (payload.resourceSpans as Array>)[0];
+ const attrs = (rs.resource as { attributes: Array<{ key: string; value: { stringValue: string } }> }).attributes;
+ const byKey = new Map(attrs.map((a) => [a.key, a.value.stringValue]));
+ assert.equal(byKey.get("service.name"), "jaiph");
+ assert.equal(byKey.get("jaiph.version"), "9.9.9");
+ assert.equal(byKey.get("deployment.environment"), "ci");
+ assert.equal(byKey.get("jaiph.run_id"), RUN_ID);
+ assert.equal(byKey.get("jaiph.workflow"), "default");
+ assert.equal(byKey.get("jaiph.source"), "fib.jh");
+});
+
+test("runSummaryToOtlp: ids are deterministic across two invocations", () => {
+ const a = JSON.stringify(runSummaryToOtlp(fixtureLines(), META));
+ const b = JSON.stringify(runSummaryToOtlp(fixtureLines(), META));
+ assert.equal(a, b);
+});
+
+test("resolveOtlpEndpoint: traces-specific verbatim; generic gets /v1/traces; traces wins", () => {
+ assert.equal(resolveOtlpEndpoint({}), undefined);
+ assert.equal(
+ resolveOtlpEndpoint({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://c:4318/v1/traces" }),
+ "http://c:4318/v1/traces",
+ );
+ assert.equal(
+ resolveOtlpEndpoint({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318" }),
+ "http://c:4318/v1/traces",
+ );
+ assert.equal(
+ resolveOtlpEndpoint({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318/" }),
+ "http://c:4318/v1/traces",
+ "a trailing slash on the base endpoint is not doubled",
+ );
+ assert.equal(
+ resolveOtlpEndpoint({
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://traces:4318/v1/traces",
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://base:4318",
+ }),
+ "http://traces:4318/v1/traces",
+ "traces-specific wins over generic",
+ );
+});
+
+test("parseKeyValueList: comma-separated k=v with = allowed in values", () => {
+ assert.deepEqual(parseKeyValueList(undefined), {});
+ assert.deepEqual(parseKeyValueList(""), {});
+ assert.deepEqual(parseKeyValueList("a=1, b=2"), { a: "1", b: "2" });
+ assert.deepEqual(
+ parseKeyValueList("authorization=Bearer abc=def"),
+ { authorization: "Bearer abc=def" },
+ "only the first = splits key from value",
+ );
+ assert.deepEqual(parseKeyValueList("bad,c=3"), { c: "3" }, "entries without = are skipped");
+});
+
+test("exportRunTelemetry: warns and skips when the protocol is not http/json", async () => {
+ const captured: string[] = [];
+ const original = process.stderr.write.bind(process.stderr);
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
+ captured.push(s);
+ return true;
+ };
+ try {
+ await exportRunTelemetry({
+ runDir: "/nonexistent",
+ workflow: "default",
+ exitStatus: 0,
+ signal: null,
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
+ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc",
+ },
+ });
+ } finally {
+ (process.stderr as unknown as { write: typeof original }).write = original;
+ }
+ assert.equal(captured.length, 1, "exactly one warning line");
+ assert.match(captured[0], /http\/json/);
+ assert.match(captured[0], /grpc/);
+});
+
+test("exportRunTelemetry: no OTLP endpoint → no-op, no output", async () => {
+ const captured: string[] = [];
+ const original = process.stderr.write.bind(process.stderr);
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
+ captured.push(s);
+ return true;
+ };
+ try {
+ await exportRunTelemetry({ runDir: "/nonexistent", workflow: "default", exitStatus: 0, signal: null, env: {} });
+ } finally {
+ (process.stderr as unknown as { write: typeof original }).write = original;
+ }
+ assert.equal(captured.length, 0);
+});
diff --git a/src/cli/telemetry/otlp.ts b/src/cli/telemetry/otlp.ts
new file mode 100644
index 00000000..fdf8d9d0
--- /dev/null
+++ b/src/cli/telemetry/otlp.ts
@@ -0,0 +1,390 @@
+/**
+ * OTLP/HTTP trace export for a completed run.
+ *
+ * Export happens host-side, after the run reaches terminal state, by reading the
+ * run's `run_summary.jsonl` — never inside the runtime/emitter. The journal is
+ * complete (it carries the WORKFLOW and PROMPT records the live stderr stream omits),
+ * already credential-redacted, and host-visible in every sandbox mode, so nothing
+ * new crosses the container boundary. Telemetry is never load-bearing: an
+ * unreachable or erroring collector produces exactly one stderr warning and never
+ * affects the run's exit code, output, or journal.
+ *
+ * Only OTLP/HTTP with a JSON payload (`http/json`) is spoken. Zero runtime
+ * dependencies — a `node:https`/`node:http` request is the whole transport.
+ */
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { createHash } from "node:crypto";
+import http from "node:http";
+import https from "node:https";
+import { VERSION } from "../../version";
+
+/** Metadata the pure mapper needs beyond the journal lines themselves. */
+export interface OtlpMeta {
+ /** Root workflow symbol (`default` for `jaiph run`, the tool symbol for MCP). */
+ workflow: string;
+ /** Child exit status; nonzero marks the root span ERROR. */
+ exitStatus: number;
+ /** Terminating signal, when the run was killed; marks the root span ERROR. */
+ signal: string | null;
+ /** `service.name` resource attribute (OTEL_SERVICE_NAME, default `jaiph`). */
+ serviceName: string;
+ /** Extra resource attributes (OTEL_RESOURCE_ATTRIBUTES pairs + `jaiph.version`). */
+ resourceAttributes: Record;
+}
+
+/** One OTLP AnyValue-wrapped attribute. */
+interface OtlpAttr {
+ key: string;
+ value: { stringValue: string } | { intValue: string };
+}
+
+type JournalEvent = Record;
+
+/** SPAN_KIND_INTERNAL. */
+const SPAN_KIND_INTERNAL = 1;
+/** STATUS_CODE_OK / STATUS_CODE_ERROR. */
+const STATUS_CODE_OK = 1;
+const STATUS_CODE_ERROR = 2;
+
+function sha256hex(data: string): string {
+ return createHash("sha256").update(data, "utf8").digest("hex");
+}
+
+/** Span id = first 16 hex chars of sha256(). Deterministic per journal. */
+function spanIdFor(eventId: string): string {
+ return sha256hex(eventId).slice(0, 16);
+}
+
+/** ISO timestamp (`2026-04-21T16:02:18Z`) → nanoseconds-since-epoch string. */
+function tsToNano(ts: string | undefined): string {
+ if (!ts) return "0";
+ const ms = Date.parse(ts);
+ if (Number.isNaN(ms)) return "0";
+ return (BigInt(ms) * 1_000_000n).toString();
+}
+
+function strAttr(key: string, value: string): OtlpAttr {
+ return { key, value: { stringValue: value } };
+}
+
+function intAttr(key: string, value: number): OtlpAttr {
+ return { key, value: { intValue: String(value) } };
+}
+
+function asString(v: unknown): string {
+ return typeof v === "string" ? v : "";
+}
+
+function asNumberOrNull(v: unknown): number | null {
+ return typeof v === "number" ? v : null;
+}
+
+/**
+ * Map journal lines + run metadata to an OTLP/HTTP JSON `ExportTraceServiceRequest`.
+ *
+ * Pure: identical input yields byte-identical output, so re-exporting a run
+ * produces the same trace/span ids (trace id = run id UUID with dashes stripped;
+ * span id = first 16 hex of sha256(event id)).
+ */
+export function runSummaryToOtlp(lines: string[], meta: OtlpMeta): Record {
+ const events: JournalEvent[] = [];
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) continue;
+ try {
+ events.push(JSON.parse(trimmed) as JournalEvent);
+ } catch {
+ // A malformed line never blocks the export — telemetry is best-effort.
+ }
+ }
+
+ const runId =
+ asString(events.find((e) => typeof e.run_id === "string")?.run_id) || "";
+ const traceId = runId.replace(/-/g, "");
+ const rootSpanId = spanIdFor(runId);
+
+ const wfStart = events.find((e) => e.type === "WORKFLOW_START");
+ const wfEnd = events.find((e) => e.type === "WORKFLOW_END");
+ const firstTs = asString(events[0]?.ts);
+ const lastTs = asString(events[events.length - 1]?.ts) || firstTs;
+ const rootStart = asString(wfStart?.ts) || firstTs;
+ const rootEnd = asString(wfEnd?.ts) || lastTs;
+ const source = asString(wfStart?.source) || asString(wfEnd?.source);
+ const runFailed = meta.exitStatus !== 0 || meta.signal != null;
+
+ const spans: Record[] = [];
+
+ // Root span — one per run.
+ spans.push({
+ traceId,
+ spanId: rootSpanId,
+ name: `workflow ${meta.workflow}`,
+ kind: SPAN_KIND_INTERNAL,
+ startTimeUnixNano: tsToNano(rootStart),
+ endTimeUnixNano: tsToNano(rootEnd),
+ status: runFailed
+ ? {
+ code: STATUS_CODE_ERROR,
+ message: meta.signal ? `terminated by signal ${meta.signal}` : `exit status ${meta.exitStatus}`,
+ }
+ : { code: STATUS_CODE_OK },
+ events: logSpanEvents(events),
+ });
+
+ // One span per STEP_START/STEP_END pair, matched by event id.
+ const stepEnds = new Map();
+ for (const e of events) {
+ if (e.type === "STEP_END" && typeof e.id === "string") stepEnds.set(e.id, e);
+ }
+ for (const start of events) {
+ if (start.type !== "STEP_START" || typeof start.id !== "string") continue;
+ const end = stepEnds.get(start.id);
+ const parentId = asString(start.parent_id);
+ const status = end ? asNumberOrNull(end.status) : null;
+ const failed = end ? status != null && status !== 0 : true;
+ const attrs: OtlpAttr[] = [
+ strAttr("jaiph.step.kind", asString(start.kind)),
+ strAttr("jaiph.step.func", asString(start.func)),
+ strAttr("jaiph.step.name", asString(start.name)),
+ intAttr("jaiph.step.seq", asNumberOrNull(start.seq) ?? 0),
+ intAttr("jaiph.step.depth", asNumberOrNull(start.depth) ?? 0),
+ ];
+ if (status != null) attrs.push(intAttr("jaiph.step.status", status));
+ const elapsed = end ? asNumberOrNull(end.elapsed_ms) : null;
+ if (elapsed != null) attrs.push(intAttr("jaiph.step.elapsed_ms", elapsed));
+ // Redacted-in-journal captures — surfaces step output in the trace without
+ // ever touching the raw (unredacted) capture files.
+ const outContent = end ? asString(end.out_content) : "";
+ if (outContent.length > 0) attrs.push(strAttr("jaiph.step.out", outContent));
+ const errContent = end ? asString(end.err_content) : "";
+ if (errContent.length > 0) attrs.push(strAttr("jaiph.step.err", errContent));
+ spans.push({
+ traceId,
+ spanId: spanIdFor(start.id),
+ parentSpanId: parentId ? spanIdFor(parentId) : rootSpanId,
+ name: `${asString(start.kind)} ${asString(start.name)}`.trim(),
+ kind: SPAN_KIND_INTERNAL,
+ startTimeUnixNano: tsToNano(asString(start.ts)),
+ endTimeUnixNano: tsToNano(end ? asString(end.ts) : lastTs),
+ status: failed ? { code: STATUS_CODE_ERROR } : { code: STATUS_CODE_OK },
+ attributes: attrs,
+ });
+ }
+
+ // PROMPT_START/PROMPT_END pairs → child spans of their step_id. No `id` field,
+ // so pair FIFO per step_id and derive a deterministic span id from the ordinal.
+ const openPrompts = new Map>();
+ let promptIndex = 0;
+ for (const e of events) {
+ if (e.type === "PROMPT_START") {
+ const stepId = asString(e.step_id);
+ const list = openPrompts.get(stepId) ?? [];
+ list.push({ start: e, index: promptIndex });
+ openPrompts.set(stepId, list);
+ promptIndex += 1;
+ } else if (e.type === "PROMPT_END") {
+ const stepId = asString(e.step_id);
+ const open = openPrompts.get(stepId);
+ const match = open?.shift();
+ if (match) spans.push(promptSpan(traceId, rootSpanId, match.start, e, match.index, lastTs));
+ }
+ }
+ // Unmatched PROMPT_START (crash mid-prompt) closes at the last event, ERROR.
+ for (const list of openPrompts.values()) {
+ for (const { start, index } of list) {
+ spans.push(promptSpan(traceId, rootSpanId, start, null, index, lastTs));
+ }
+ }
+
+ return {
+ resourceSpans: [
+ {
+ resource: { attributes: resourceAttributes(meta, runId, source) },
+ scopeSpans: [{ scope: { name: "jaiph", version: VERSION }, spans }],
+ },
+ ],
+ };
+}
+
+function promptSpan(
+ traceId: string,
+ rootSpanId: string,
+ start: JournalEvent,
+ end: JournalEvent | null,
+ index: number,
+ lastTs: string,
+): Record {
+ const stepId = asString(start.step_id);
+ const status = end ? asNumberOrNull(end.status) : null;
+ const failed = end ? status != null && status !== 0 : true;
+ const attrs: OtlpAttr[] = [strAttr("jaiph.prompt.backend", asString(start.backend))];
+ const model = asString(end?.model) || asString(start.model);
+ if (model.length > 0) attrs.push(strAttr("jaiph.prompt.model", model));
+ if (status != null) attrs.push(intAttr("jaiph.prompt.status", status));
+ return {
+ traceId,
+ spanId: spanIdFor(`prompt:${stepId}:${index}`),
+ parentSpanId: stepId ? spanIdFor(stepId) : rootSpanId,
+ name: `prompt ${asString(start.backend)}`.trim(),
+ kind: SPAN_KIND_INTERNAL,
+ startTimeUnixNano: tsToNano(asString(start.ts)),
+ endTimeUnixNano: tsToNano(end ? asString(end.ts) : lastTs),
+ status: failed ? { code: STATUS_CODE_ERROR } : { code: STATUS_CODE_OK },
+ attributes: attrs,
+ };
+}
+
+/** LOGERR/LOGWARN events become span events on the root span. */
+function logSpanEvents(events: JournalEvent[]): Record[] {
+ const out: Record[] = [];
+ for (const e of events) {
+ if (e.type !== "LOGERR" && e.type !== "LOGWARN") continue;
+ out.push({
+ timeUnixNano: tsToNano(asString(e.ts)),
+ name: "log",
+ attributes: [strAttr("level", asString(e.type)), strAttr("message", asString(e.message))],
+ });
+ }
+ return out;
+}
+
+function resourceAttributes(meta: OtlpMeta, runId: string, source: string): OtlpAttr[] {
+ const merged: Record = {
+ "service.name": meta.serviceName,
+ ...meta.resourceAttributes,
+ "jaiph.run_id": runId,
+ "jaiph.workflow": meta.workflow,
+ "jaiph.source": source,
+ };
+ return Object.entries(merged).map(([k, v]) => strAttr(k, v));
+}
+
+/**
+ * Resolve the OTLP traces endpoint from standard OTEL env. The traces-specific
+ * endpoint is used verbatim; the generic endpoint is a base URL with `/v1/traces`
+ * appended. Traces-specific wins when both are set. Returns undefined (export
+ * disabled) when neither is set.
+ */
+export function resolveOtlpEndpoint(env: NodeJS.ProcessEnv): string | undefined {
+ const traces = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim();
+ if (traces) return traces;
+ const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
+ if (base) return `${base.replace(/\/+$/, "")}/v1/traces`;
+ return undefined;
+}
+
+/** Parse a comma-separated `k=v` list (OTEL_EXPORTER_OTLP_HEADERS / _RESOURCE_ATTRIBUTES). */
+export function parseKeyValueList(raw: string | undefined): Record {
+ const out: Record = {};
+ if (!raw) return out;
+ for (const part of raw.split(",")) {
+ const p = part.trim();
+ if (p.length === 0) continue;
+ const eq = p.indexOf("=");
+ if (eq <= 0) continue;
+ const key = p.slice(0, eq).trim();
+ const value = p.slice(eq + 1).trim();
+ if (key.length > 0) out[key] = value;
+ }
+ return out;
+}
+
+/** POST the payload as JSON with a 10 s timeout. Rejects on non-2xx / transport error. */
+function postOtlp(
+ endpoint: string,
+ payload: Record,
+ headers: Record,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let url: URL;
+ try {
+ url = new URL(endpoint);
+ } catch {
+ reject(new Error(`invalid endpoint ${endpoint}`));
+ return;
+ }
+ const body = JSON.stringify(payload);
+ const lib = url.protocol === "https:" ? https : http;
+ const req = lib.request(
+ url,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "content-length": Buffer.byteLength(body),
+ ...headers,
+ },
+ },
+ (res) => {
+ res.resume();
+ res.on("end", () => {
+ const code = res.statusCode ?? 0;
+ if (code >= 200 && code < 300) resolve();
+ else reject(new Error(`collector returned HTTP ${code}`));
+ });
+ },
+ );
+ req.setTimeout(10_000, () => req.destroy(new Error("timed out after 10s")));
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+/** Options for the shared post-run export hook. */
+export interface ExportRunTelemetryOptions {
+ /** Absolute host run directory; its `run_summary.jsonl` is the export source. */
+ runDir?: string;
+ workflow: string;
+ exitStatus: number;
+ signal: string | null;
+ env: NodeJS.ProcessEnv;
+}
+
+/**
+ * Single shared post-run hook, invoked wherever a run reaches terminal state on
+ * the host (`jaiph run` completion, the shared workflow-call layer). Enabled iff
+ * an OTLP traces endpoint is configured. Best-effort: any failure produces one
+ * stderr warning and nothing else.
+ */
+export async function exportRunTelemetry(opts: ExportRunTelemetryOptions): Promise {
+ const { runDir, workflow, exitStatus, signal, env } = opts;
+ const endpoint = resolveOtlpEndpoint(env);
+ if (!endpoint) return;
+
+ const protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim();
+ if (protocol && protocol !== "http/json") {
+ process.stderr.write(
+ `jaiph: OTLP trace export skipped — unsupported OTEL_EXPORTER_OTLP_PROTOCOL "${protocol}" (only http/json is supported)\n`,
+ );
+ return;
+ }
+
+ if (!runDir) return;
+ const summaryFile = join(runDir, "run_summary.jsonl");
+ if (!existsSync(summaryFile)) return;
+ let lines: string[];
+ try {
+ lines = readFileSync(summaryFile, "utf8").split("\n");
+ } catch {
+ return;
+ }
+ if (lines.every((l) => l.trim().length === 0)) return;
+
+ const serviceName = env.OTEL_SERVICE_NAME?.trim() || "jaiph";
+ const resourceAttrs = {
+ ...parseKeyValueList(env.OTEL_RESOURCE_ATTRIBUTES),
+ "jaiph.version": VERSION,
+ };
+ const payload = runSummaryToOtlp(lines, { workflow, exitStatus, signal, serviceName, resourceAttributes: resourceAttrs });
+ const headers = parseKeyValueList(env.OTEL_EXPORTER_OTLP_HEADERS);
+
+ try {
+ await postOtlp(endpoint, payload, headers);
+ } catch (err) {
+ process.stderr.write(
+ `jaiph: OTLP trace export failed — ${err instanceof Error ? err.message : String(err)}\n`,
+ );
+ }
+}
From f48df9f21d5e37bbca06a82d745125b32876af55 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 09:41:23 +0200
Subject: [PATCH 04/24] Feat: report failed runs to Sentry as error events
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A run that terminates unsuccessfully (nonzero exit or a signal) is now
pushed to a Sentry error tracker as a single error event, enabled iff
SENTRY_DSN is set. Operators running workflows on a schedule or as a
service get alerting and grouping without scraping run directories.
The new dependency-free src/cli/telemetry/sentry.ts hand-rolls a Sentry
envelope POST over node:https, sharing the timeout-guarded POST helper
extracted into src/cli/telemetry/http.ts (otlp.ts now reuses it too).
Reporting fires from the single shared post-run hook that all modes
funnel through, so it is one choke point across jaiph run and the
MCP/HTTP call layer. Event content — message, tags, extra, fingerprint,
release, environment — is sourced only from the already credential-
redacted run_summary.jsonl; re-occurrences group per workflow + failing
step. Like trace export it is host-side, end-of-run, and never load-
bearing: unreachable Sentry, a non-2xx response, or a timeout produces
exactly one stderr warning and leaves the run's exit code and output
untouched. Successful runs send nothing.
SENTRY_DSN/SENTRY_ENVIRONMENT/SENTRY_RELEASE are documented in the
env-vars telemetry section and docs/observability.md. No JAIPH_* vars
added; dependencies remain empty.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
QUEUE.md | 32 ----
README.md | 1 +
docs/env-vars.md | 13 ++
docs/index.html | 6 +
docs/observability.md | 49 +++++-
integration/sentry-export.test.ts | 267 ++++++++++++++++++++++++++++++
src/cli/telemetry/http.ts | 54 ++++++
src/cli/telemetry/otlp.ts | 59 +++----
src/cli/telemetry/sentry.test.ts | 175 ++++++++++++++++++++
src/cli/telemetry/sentry.ts | 210 +++++++++++++++++++++++
11 files changed, 797 insertions(+), 73 deletions(-)
create mode 100644 integration/sentry-export.test.ts
create mode 100644 src/cli/telemetry/http.ts
create mode 100644 src/cli/telemetry/sentry.test.ts
create mode 100644 src/cli/telemetry/sentry.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38cc63fb..16ff218f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,14 @@
- **Export traces to an OTLP collector:** every run now exports one OpenTelemetry span tree (workflow → steps → prompts) to any OTLP/HTTP collector — a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog — enabled by the standard `OTEL_EXPORTER_OTLP_ENDPOINT`. Export is host-side and end-of-run (it reads the run's already credential-redacted `run_summary.jsonl`), adds no runtime dependencies, and is never load-bearing: an unreachable or erroring collector produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched.
+- **Report failed runs to Sentry:** a run that terminates *unsuccessfully* (nonzero exit or a signal) is now pushed to a Sentry error tracker as one error event — enabled by the standard `SENTRY_DSN` — so operators running workflows on a schedule or as a service get alerting and grouping without scraping run directories. The event carries the workflow, the failing step, a credential-redacted output excerpt, and a run-dir pointer, and groups re-occurrences per workflow + failing step. Like trace export it is host-side, end-of-run, adds no runtime dependencies, and is never load-bearing: an unreachable or erroring Sentry produces exactly one stderr warning and leaves the run's exit code and output untouched. Successful runs send nothing.
+
## All changes
- **Feat — OTLP trace export: one span tree per run, zero dependencies:** A completed run now exports as an OpenTelemetry trace to any OTLP/HTTP collector (a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog), so operators running workflows in CI or as a service get a per-run latency breakdown and failure signal in a standard observability stack instead of only the local run dir. Export is **host-side and end-of-run**, in the new `src/cli/telemetry/otlp.ts` (zero runtime dependencies — a `node:https`/`node:http` request is the whole transport): after a run reaches terminal state the CLI reads *that run's* `run_summary.jsonl` and posts one trace. Nothing new crosses the sandbox boundary and no `OTEL_*` variable is forwarded into the container — the journal is complete (it carries the `WORKFLOW_*`/`PROMPT_*` records the live stderr stream omits), already credential-redacted by `RuntimeEventEmitter`, and host-visible in every sandbox mode (the run dir is a host mount); runs are minutes long, so end-of-run batching is the normal OTLP pattern anyway. The pure `runSummaryToOtlp(lines, meta)` maps journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`: **trace id** = the run-id UUID with dashes stripped (32 hex, per the spec's JSON mapping), **span id** = first 16 hex of `sha256()` — deterministic, so re-exporting a run yields byte-identical ids. One **root span** `workflow ` per run (`WORKFLOW_START`→`WORKFLOW_END` timestamps, falling back to first/last event `ts`; status `ERROR` when the run exits nonzero or a signal killed it, else `OK`; each `LOGERR`/`LOGWARN` becomes a span event on it); one **step span** per `STEP_START`/`STEP_END` pair matched by event `id`, parented via `parent_id` (root when null), `kind: SPAN_KIND_INTERNAL`, attributes `jaiph.step.{kind,func,name,seq,depth,status,elapsed_ms}` plus the redacted `jaiph.step.out`/`jaiph.step.err` captures, span status `ERROR` on a nonzero step status; **prompt spans** as children of their `step_id` with `jaiph.prompt.{backend,model,status}`. A `STEP_START` with no matching `STEP_END` (a crash) closes at the last event's `ts` with status `ERROR`; ISO `ts` → `timeUnixNano` strings. Resource attributes: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), the `OTEL_RESOURCE_ATTRIBUTES` pairs, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`. **Enablement is standard OTEL env — no new `JAIPH_*`:** export runs iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set (traces-specific wins when both are); `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) is applied; only `http/json` is spoken — any other `OTEL_EXPORTER_OTLP_PROTOCOL` (e.g. `grpc`) warns on stderr and skips rather than mis-speak a protocol. A single shared hook `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})` fires at every host terminal state — `jaiph run` completion (`reportResult` in `src/cli/commands/run.ts`) and the shared workflow-call layer (`src/cli/exec/call.ts`, covering every MCP `tools/call` and `jaiph serve` invocation) — one choke point covering host, Docker snapshot, and inplace modes. **Telemetry is never load-bearing:** an unreachable or erroring collector (or the 10 s timeout) produces exactly one stderr warning line and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/otlp.test.ts` (fixture journals — trace id from run id, step parenting per `parent_id`, prompt child of `step_id` with backend/model, failed step → span status 2, nonzero exit → root status 2, ISO→nano strings, unmatched `STEP_START` closes ERROR at the last event time, deterministic ids across two invocations; endpoint-resolution precedence, header parsing, `http/json`-only guard) and `integration/otlp-export.test.ts` (a fake collector receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set, and nothing with no OTEL env; a `500` and a connection-refused collector each leave exit `0` with exactly one stderr warning; a shared-call/MCP invocation triggers exactly one export per call; a credential echoed in step output appears in the payload only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: new [Export traces to an OTLP collector](observability.md) how-to, a "Telemetry variables" section in [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — "OTEL + Sentry configurable would be the easiest way".)
+- **Feat — Sentry error reporting on failed runs, zero dependencies:** A run that reaches an *unsuccessful* terminal state (nonzero exit or a terminating signal) is now reported to a Sentry error tracker as one **error event**, so teams running jaiph workflows on schedules or as a service (CI, Kubernetes) get failures pushed to their tracker with enough context to triage — instead of only a local run dir and a nonzero exit code. The new `src/cli/telemetry/sentry.ts` (zero runtime dependencies — a hand-rolled Sentry **envelope** POST is the whole transport) fires from the **same host-side, end-of-run choke point** that exports OTLP traces: the shared `exportRunTelemetry` hook (`src/cli/telemetry/otlp.ts`) now dispatches two independent, best-effort exporters — OTLP traces (every run, when a collector is set) and the Sentry report (**failed runs only**, when `SENTRY_DSN` is set) — so `jaiph run` completion and every workflow invoked through `jaiph mcp` / `jaiph serve` are all covered by one path. **Successful runs send nothing.** The timeout-guarded POST was extracted into a shared `src/cli/telemetry/http.ts` (`postWithTimeout`, `node:http`/`node:https`) that both exporters reuse. **Enablement is a standard Sentry DSN — no new `JAIPH_*`:** reporting is on iff `SENTRY_DSN` is set; the pure `parseSentryDsn` maps `https://@/` to the envelope endpoint `https:///api//envelope/` and the `X-Sentry-Auth: Sentry sentry_version=7, sentry_key=, sentry_client=jaiph/` header (a non-default port is preserved), and a malformed DSN produces exactly one stderr warning and no send. The pure `buildSentryEvent(lines, meta)` maps the run's `run_summary.jsonl` lines + `{workflow, exitStatus, signal, runDir, release, environment}` to the event, sourced **entirely from `run_summary.jsonl`** — already credential-redacted by `RuntimeEventEmitter`, never the raw `.out`/`.err` captures — so a secret in step output reaches Sentry only as `[REDACTED]`: **`event_id`** = the run-id UUID with dashes stripped; `timestamp`; `platform: node`; `level: error`; **`message.formatted`** = `workflow failed (exit N)`, or `terminated by signal S` (the signal wins over the exit code); **`tags`** `jaiph.workflow`, `jaiph.source` (source file basename), and the first failing `STEP_END`'s `jaiph.step.kind` / `jaiph.step.name` when known; **`extra`** `failing_step_detail` (that step's redacted `err_content`/`out_content` excerpt) and `run_dir`; **`fingerprint`** `["jaiph", , ]`, so re-occurrences group per workflow + failing step; **`release`** from `SENTRY_RELEASE` or `jaiph@`, and **`environment`** from `SENTRY_ENVIRONMENT` when set. `buildEnvelope` frames the body as three newline-separated JSON documents — an envelope header (`event_id` + `sent_at`), an item header (`type: event`), and the event JSON. **Never load-bearing:** an unreachable or erroring Sentry, a non-2xx status, or the 10 s timeout produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/sentry.test.ts` (DSN endpoint + auth header, non-default port preserved, malformed → null/no-send; `event_id` matches the run-id hex; exit-code vs signal message, fingerprint, tags, and redacted-source excerpt; environment included only when set; no-failing-step → `unknown` fingerprint tail and no step tags; envelope framing as three JSON documents; a successful run and a failed run without a DSN send and warn nothing, a malformed DSN warns exactly once) and `integration/sentry-export.test.ts` (a fake Sentry HTTP server — a failing `jaiph run` with `SENTRY_DSN` delivers exactly one envelope carrying the failing step tag and redacted excerpt; a succeeding run and a failing run **without** `SENTRY_DSN` deliver nothing; a `500` and a connection-refused Sentry each leave the exit code at the no-DSN baseline with exactly one stderr warning; a credential echoed in the failing step's output reaches the delivered event only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: a new [Report failed runs to Sentry](observability.md#report-failed-runs-to-sentry) section in [Export traces to an OTLP collector](observability.md), `SENTRY_DSN` / `SENTRY_ENVIRONMENT` / `SENTRY_RELEASE` rows in the telemetry section of [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — the Sentry half of "OTEL + Sentry configurable".)
+
- **Feat — `jaiph serve` run inspection: live event stream + artifact download:** A `jaiph serve` client can now watch what a run is doing while it executes and retrieve the files it publishes, not just read its terminal result. Three new bearer-gated endpoints (`src/cli/serve/runfiles.ts` + routes in `src/cli/serve/handler.ts`, documented in the generated OpenAPI via `src/cli/serve/openapi.ts`) read from the run's durable, host-visible run directory — the same `run_summary.jsonl` journal and `artifacts/` tree `jaiph run` writes. **`GET /v1/runs/{id}/events`** serves the run's `run_summary.jsonl`: by default the whole journal as `application/x-ndjson`, verbatim, then closes; with `Accept: text/event-stream` it switches to **Server-Sent Events** — `streamRunEventsSse` replays every existing line as `data: `, then follows the file as it appends (polling ~250 ms), emits a `:ka` keep-alive comment every 15 s, and closes with `event: end` once the server's run registry marks the run terminal (so it works identically for a still-running run and an already-terminal one: full replay plus an immediate `end`). **`GET /v1/runs/{id}/artifacts`** returns `{artifacts: [{path, size, mtime}, …]}` for every regular file under the run's `artifacts/` (recursive, `[]` when none). **`GET /v1/runs/{id}/artifacts/{path}`** downloads one file as `application/octet-stream` with a `Content-Disposition` filename. All three are `404` on an unknown run id and `401` unauthenticated. To reach a run that is *still executing* — whose dir is only recorded on its record at finalize — the server injects a `resolveRunDir` resolver (`src/cli/commands/serve.ts`) that falls back to scanning the host runs root for the run id via `findRunDir` (`src/cli/shared/errors.ts`), which also resolves the **Docker-mode** host-side run dir (the run dir is a host mount in every sandbox mode). The HTTP layer grew a streaming path: `ServeResponse` now carries an optional `bodyBuffer` (binary bodies — NDJSON journal, artifact bytes) and a `stream(target)` hook driven by `src/cli/serve/server.ts`'s `makeStreamTarget`, which wires SSE writes to the socket and flips `aborted` (stopping the follow loop) the moment the client disconnects. **Security:** the journal is served **verbatim** — the credential redaction `RuntimeEventEmitter` already applies (values of `*_API_KEY` / `*_TOKEN` / `*_SECRET` env vars → `[REDACTED]`) *is* the redaction guarantee — and the raw per-step `%06d-*.out` / `.err` capture files are unreachable **by construction**: `listArtifacts` walks only the `artifacts/` subtree (the captures live in the run-dir root), and downloads go through `resolveArtifactPath`, which is traversal-proof via three guards checked before any read — reject empty/NUL requests, lexical containment (`resolve()` collapses `..`/absolute paths and the result must sit under `artifacts/`), and **symlink** containment (`realpathSync` on both the artifacts dir and the candidate — an `artifacts/` symlink pointing outside is rejected without reading its target, while one pointing inside still serves). Tests: `src/cli/serve/runfiles.test.ts` (artifact listing skips `.out`/`.err` captures; the full traversal battery — `../`, absolute, empty/NUL, directory, escaping symlink rejected without reading the target, inside-pointing symlink served, and a capture-file symlink rejected; SSE replays a terminal journal as `data:` frames then `event: end`, and stops promptly on client disconnect), `integration/serve-server.test.ts` (SSE mid-run: replayed `WORKFLOW_START`, a `STEP_END` **before** the run is terminal, `event: end` + socket close after completion, and the concatenated `data:` payloads equal the final journal line set; NDJSON on a terminal run byte-matches the journal; unknown run → `404`; unauthenticated → `401`; a credential echoed by a run is absent and `[REDACTED]` present in the stream; artifacts list-then-download round-trips byte-identically and a traversal is `404`), and `e2e/tests/147_serve_http_api.sh` (NDJSON byte-matches `run_summary.jsonl`; artifact list + byte-identical download; URL-encoded `%2e%2e` traversal → `404`). Docs: "Watch a run as it executes" and "Download a run's artifacts" sections in [Serve workflows over HTTP](serve.md) (with the raw-captures non-exposure called out as a security property), and the three endpoint rows in the `jaiph serve` table in [CLI](cli.md). (Deployability feedback `2026-07-23` — "a UI on top to be able to inspect what it is doing"; contract in `design/2026-07-23-serve-http-api.md` § Events streaming.)
- **Feat — `jaiph serve`: HTTP API with OpenAPI 3.1 + Swagger UI:** A new command turns a `.jh` file into a network-reachable, self-describing HTTP service, complementing `jaiph mcp` (which exposes the same workflows only over stdio JSON-RPC to a co-located parent). `jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... ` (default `127.0.0.1:5247`) is dispatched from `src/cli/index.ts` and implemented in `src/cli/commands/serve.ts` + `src/cli/serve/`, hand-rolled on `node:http` with **no runtime npm dependencies** (project policy — `package.json` `dependencies` stays absent; the MCP server set the precedent). Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (errors to stderr, exit `1`), `--env` resolved once via `resolveEnvPairs`, Docker config + image prepared once, credential pre-flight as warnings, and a sandbox-mode notice; all logs go to stderr and one startup line prints the listen URL and the `/docs` URL. Endpoints: `GET /` → `302 /docs`; unauthenticated `GET /healthz`, `GET /openapi.json`, `GET /docs`; and bearer-gated `GET /v1/workflows`, `POST /v1/workflows/{name}/runs` (async `202` + `Location`, or `?wait=true` → terminal `200`), `GET /v1/runs`, `GET /v1/runs/{id}`, `POST /v1/runs/{id}/cancel`. A run is a durable resource `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` with `status ∈ running|succeeded|failed|cancelled`; **a workflow failure is not an HTTP error** — it comes back `200`/`202` with `status:"failed"` and the same failure narrative `jaiph mcp` returns. Errors use `{error:{code,message}}`: `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415 E_UNSUPPORTED_MEDIA_TYPE` (non-`application/json` body), `429 E_TOO_MANY_RUNS`. Workflow exposure, naming, descriptions, and request-body schemas come from the shared `deriveTools` (`src/cli/mcp/tools.ts`) — identical rules to MCP (export-narrowing, route-target exclusion, `default` handling, required-string params, `additionalProperties:false`), with param validation mirroring MCP's `-32602` rules as HTTP `400`. **Auth:** bearer token from `JAIPH_SERVE_TOKEN` (env only, never argv), constant-time compared, required on all `/v1/*`; `/healthz`, `/openapi.json`, `/docs` stay unauthenticated (schema metadata only, a documented trade-off) — and binding a non-loopback `--host` without the token set is a startup error before any socket opens. **Limits:** `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs → `429`. `GET /openapi.json` returns OpenAPI **3.1.0** generated per request by the pure `buildOpenApi(tools, serverInfo)` (`src/cli/serve/openapi.ts`) — one concrete path per workflow (own `operationId`, `#`-comment description, MCP input schema as request body), the run-resource paths, run/error component schemas, and a bearer `securityScheme`. `GET /docs` serves a static Swagger UI shell (`src/cli/serve/docs.ts`) loading `swagger-ui-dist` from a CDN with a **pinned exact version + SRI `integrity` hashes + `crossorigin`** on both assets and `SwaggerUIBundle({url:"/openapi.json", persistAuthorization:true})` — no vendored assets, so `/docs` needs browser internet access and `/openapi.json` is the offline fallback (air-gap consequence recorded in the design doc). Execution reuses the MCP call layer, **moved** from `src/cli/mcp/call.ts` to `src/cli/exec/call.ts` (`McpCallResult` → `WorkflowCallResult`, caller now supplies `runId`, result extended with `{runDir?, exitStatus?, signal?}`); sandbox selection, `--env` passthrough, and cancellation (child kill + `stopDockerContainer`) work exactly as for MCP calls. The generation/hot-reload machinery (`loadState`, watch/rewatch, generation dirs) was extracted from `src/cli/commands/mcp.ts` into `src/cli/shared/generation.ts` and is now shared by both commands; editing a served source re-derives the workflow set and the OpenAPI document with no restart, and a superseded generation's scripts dir is deleted only after its in-flight runs finish (refcounted, since HTTP runs can outlive a reload). Shutdown: the first `SIGINT`/`SIGTERM` stops accepting and drains in-flight runs, a second signal cancels them, exit `0`. `jaiph mcp` behavior is unchanged after the extraction (its own tests prove it). Tests: `src/cli/serve/handler.test.ts` (injected-deps handler — unknown workflow `404`; missing / non-string / unexpected param `400`; cancel `202` → terminal `cancelled` with child + container teardown, cancel-on-terminal `409`; `429`/`413`/`415`; auth matrix incl. non-loopback-without-token exit `1`), `src/cli/serve/openapi.test.ts` (output passes a real OpenAPI 3.1 validator devDependency; one path per exposed workflow with the exact MCP-derived schema, covering the export-narrowing fixture), `src/cli/serve/docs.test.ts` (pinned version + `integrity` + `crossorigin` on both assets), `integration/serve-server.test.ts` (real server on port 0 — `wait=true` round-trips a `return` value into `result_text` with `status:"succeeded"`; async `202` + `Location` polled to the same terminal result; failing workflow → HTTP `200` with `status:"failed"`, `exit_status`, and `run dir:` in `result_text`; hot-reload surfaces a new workflow in `/openapi.json` and `/v1/workflows` while a pre-reload run still completes), and `e2e/tests/147_serve_http_api.sh` (wired into `e2e/test_all.sh`). Docs: new [Serve workflows over HTTP](serve.md) how-to, a `jaiph serve` section in [CLI](cli.md), `JAIPH_SERVE_TOKEN` and `JAIPH_SERVE_MAX_CONCURRENT` rows in [Environment variables](env-vars.md), `printUsage` in `src/cli/shared/usage.ts`, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23`; contract in `design/2026-07-23-serve-http-api.md`.)
diff --git a/QUEUE.md b/QUEUE.md
index 8c7d967a..cc375b37 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,38 +14,6 @@ Process rules:
***
-## Feat: Sentry error reporting on failed runs #dev-ready
-
-**Source:** Observability feedback (2026-07-23): "OTEL + Sentry configurable". This task is the Sentry half: failed workflow runs become Sentry error events so operators get alerting/grouping without scraping run dirs.
-
-**Problem:** A failed run's only trace is its local run dir and a nonzero exit code. Teams running jaiph workflows on schedules or as a service (CI, k8s) need failures pushed to their error tracker with enough context to triage — workflow, failing step, redacted output excerpt, run dir pointer.
-
-**Required behavior:**
-
-* New module `src/cli/telemetry/sentry.ts`, zero runtime dependencies — a Sentry **envelope** POST hand-rolled over `node:https` (create `src/cli/telemetry/http.ts` for the shared timeout-guarded POST helper if `src/cli/telemetry/` already has one, reuse it).
-* Enabled iff `SENTRY_DSN` is set. DSN `https://@/` parses to endpoint `https:///api//envelope/` with header `X-Sentry-Auth: Sentry sentry_version=7, sentry_key=, sentry_client=jaiph/`. Malformed DSN → one stderr warning, no send.
-* Fires **only** when a run terminates unsuccessfully (nonzero exit or signal), from the same host-side post-run hook that handles run completion for all modes (`jaiph run` and the shared MCP/HTTP call layer) — one choke point. Successful runs send nothing.
-* Event content (all excerpts sourced from the run's `run_summary.jsonl`, which is already credential-redacted — never from raw `.out`/`.err` captures):
- * `event_id` = run id UUID, dashes stripped; `timestamp`; `platform: "node"`; `level: "error"`;
- * `message.formatted` = `workflow failed (exit N)` / `terminated by signal S`;
- * `tags`: `jaiph.workflow`, `jaiph.source` (basename), failing step kind/name when known;
- * `extra`: failing step detail excerpt (the `STEP_END` `err_content`/`out_content`), `run_dir`;
- * `fingerprint`: `["jaiph", , ]` so re-occurrences group per workflow+step;
- * `release` = `SENTRY_RELEASE` or `jaiph@`; `environment` = `SENTRY_ENVIRONMENT` when set.
- * Envelope body = header line `{"event_id","sent_at"}` + item header `{"type":"event"}` + event JSON, newline-separated.
-* **Failure semantics: never load-bearing.** Unreachable Sentry, non-2xx, timeout (10 s) → exactly one stderr warning; run exit code and output untouched. No retries.
-* No new `JAIPH_*` variables expected; if any is introduced it gets its `docs/env-vars.md` row (parity lint). `SENTRY_DSN`/`SENTRY_ENVIRONMENT`/`SENTRY_RELEASE` are documented in the env-vars page's non-`JAIPH_*` telemetry section and in `docs/observability.md`.
-
-Acceptance:
-
-* Unit tests: DSN parsing (endpoint + auth header; malformed → warn/no-send), envelope framing (three newline-separated JSON documents, `event_id` matches run id hex), fingerprint and message composition for exit-code vs signal terminations.
-* Integration test with a local fake-Sentry HTTP server: a failing `jaiph run` with `SENTRY_DSN` set delivers exactly one envelope whose event carries the failing step tag and redacted excerpt; a succeeding run delivers nothing; a failing run **without** `SENTRY_DSN` delivers nothing.
-* Failure-isolation test: fake Sentry returns 500 (and separately: connection refused) — the run's exit code is unchanged from the no-DSN baseline and exactly one warning line lands on stderr.
-* Redaction test: a credential value that appears in the failing step's output shows up in the delivered event only as `[REDACTED]`.
-* `package.json` `dependencies` remains absent/empty; `npm test` passes.
-
-***
-
## Feat: standalone runtime image — run jaiph in docker/k8s without a host orchestrator #dev-ready
**Source:** Deployability feedback (2026-07-23): "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … that would allow anyone to run jaiph in docker/kubernetes" — and "it can't depend on a local machine + docker".
diff --git a/README.md b/README.md
index 8053efe0..3d5f4739 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@
- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)).
- **HTTP API** — `jaiph serve ./tools.jh` serves the same workflows over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs ([Serve workflows over HTTP](docs/serve.md)).
- **OpenTelemetry** — set the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and each run exports one span tree (workflow → steps → prompts) to any OTLP collector — Grafana Tempo, Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, zero new dependencies, never load-bearing ([Export traces to an OTLP collector](docs/observability.md)).
+- **Sentry error reporting** — set the standard `SENTRY_DSN` and every failed run (nonzero exit or a signal) is pushed to Sentry as one error event — workflow, failing step, a redacted output excerpt, and a run-dir pointer — so operators get alerting and grouping without scraping run dirs. Host-side, redacted, zero new dependencies, never load-bearing; successful runs send nothing ([Report failed runs to Sentry](docs/observability.md#report-failed-runs-to-sentry)).
## Core components
diff --git a/docs/env-vars.md b/docs/env-vars.md
index a9e19ecd..c74ada87 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -135,6 +135,19 @@ source-parity harness above. Export is enabled iff a traces endpoint is set. See
| `OTEL_SERVICE_NAME` | string | `jaiph` | `service.name` resource attribute on every exported span. |
| `OTEL_RESOURCE_ATTRIBUTES` | string (`k=v,k=v`) | — | Extra resource attributes. Jaiph also always adds `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, and `jaiph.source`. |
+Jaiph also reads the standard Sentry environment variables to report **failed**
+runs to a Sentry error tracker — again **no `JAIPH_*` variables**, consumed
+host-side after the run completes (never forwarded into the Docker sandbox), so
+they are not tracked by the `JAIPH_*` source-parity harness above. A report is
+sent iff `SENTRY_DSN` is set **and** the run terminated unsuccessfully (nonzero
+exit or a signal); successful runs send nothing. See [Observability](observability.md#report-failed-runs-to-sentry).
+
+| Variable | Type | Default | Role |
+|---|---|---|---|
+| `SENTRY_DSN` | string (DSN) | — | Sentry DSN `https://@/`. Enables failed-run reporting. A malformed DSN → one stderr warning and no send. |
+| `SENTRY_ENVIRONMENT` | string | — | Sets the event's `environment` when present (for example `prod`, `ci`). |
+| `SENTRY_RELEASE` | string | `jaiph@` | Sets the event's `release`. |
+
## Installer and `jaiph use`
These variables are consumed by `docs/install` (the installer shell script) and by `jaiph use` when it re-invokes the installer. They are **not** read from inside the Jaiph TypeScript source.
diff --git a/docs/index.html b/docs/index.html
index e6c86ff5..8128cc5a 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -574,6 +574,12 @@
Runtime
(workflow → steps → prompts) to any OTLP collector — Grafana Tempo,
Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, with zero new dependencies, and never
load-bearing. See Export traces to an OTLP collector.
+
Sentry error reporting. Set the standard
+ SENTRY_DSN and every failed run (nonzero exit or a signal) is pushed to Sentry
+ as one error event — workflow, failing step, a redacted output excerpt, and a run-dir
+ pointer — so operators get alerting and grouping without scraping run dirs. Host-side,
+ redacted, with zero new dependencies, never load-bearing; successful runs send nothing.
+ See Report failed runs to Sentry.
Samples
Jaiph source code is built mostly with real Jaiph workflows. The
diff --git a/docs/observability.md b/docs/observability.md
index 411af726..ff9bdfac 100644
--- a/docs/observability.md
+++ b/docs/observability.md
@@ -97,7 +97,52 @@ Only OTLP/HTTP with a JSON payload is spoken. If `OTEL_EXPORTER_OTLP_PROTOCOL` i
set to anything other than `http/json` (for example `grpc`), Jaiph warns and skips
the export rather than mis-speak a protocol.
+## Report failed runs to Sentry
+
+Traces cover every run; **Sentry** covers the runs you get paged about. When a run
+terminates *unsuccessfully* (nonzero exit or a signal), Jaiph posts one Sentry
+**error event** so operators get alerting and grouping without scraping run
+directories. Successful runs send nothing.
+
+Reporting is off until you set a Sentry **DSN** — there are, again, **no `JAIPH_*`
+variables** for this feature:
+
+```bash
+export SENTRY_DSN="https://@/"
+export SENTRY_ENVIRONMENT="prod" # optional — sets the event's environment
+export SENTRY_RELEASE="jaiph@1.2.3" # optional — defaults to jaiph@
+
+jaiph run ./deploy.jh
+```
+
+The same host-side, end-of-run choke point that exports traces fires the report —
+so `jaiph run` completions and workflows invoked through `jaiph mcp` / `jaiph serve`
+are all covered. A malformed DSN produces exactly one stderr warning and no send.
+
+### What the event carries
+
+Everything is sourced from the run's `run_summary.jsonl` (already
+credential-redacted), never from the raw `.out` / `.err` captures:
+
+- **`event_id`** — the run's UUID with dashes stripped, so a re-report of the same
+ run keeps the same id.
+- **`message`** — `workflow failed (exit N)`, or `terminated by signal S`.
+- **`level`** `error`, **`platform`** `node`.
+- **`tags`** — `jaiph.workflow`, `jaiph.source` (the source file basename), and the
+ failing step's `jaiph.step.kind` / `jaiph.step.name` when known.
+- **`extra`** — `failing_step_detail` (the failing step's redacted `err`/`out`
+ excerpt) and `run_dir` (a pointer to the run directory for triage).
+- **`fingerprint`** — `["jaiph", , ]`, so
+ re-occurrences group per workflow + failing step.
+- **`release`** / **`environment`** — from `SENTRY_RELEASE` / `SENTRY_ENVIRONMENT`.
+
+### Failure is never load-bearing
+
+Like trace export, a Sentry report never affects a run. An unreachable or erroring
+Sentry, a malformed DSN, or a timeout (10 s) produces **exactly one** stderr warning
+line; the run's exit code, output, and journal are untouched. There are no retries.
+
## Related
-- [Environment variables — Telemetry variables](env-vars.md#telemetry-variables) — the full list of consumed `OTEL_*` names.
-- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the `run_summary.jsonl` timeline the export reads.
+- [Environment variables — Telemetry variables](env-vars.md#telemetry-variables) — the full list of consumed `OTEL_*` / `SENTRY_*` names.
+- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the `run_summary.jsonl` timeline both exporters read.
diff --git a/integration/sentry-export.test.ts b/integration/sentry-export.test.ts
new file mode 100644
index 00000000..7523b6c7
--- /dev/null
+++ b/integration/sentry-export.test.ts
@@ -0,0 +1,267 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { createServer, type Server } from "node:http";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { AddressInfo } from "node:net";
+import { dirname, join } from "node:path";
+
+const CLI_PATH = join(process.cwd(), "dist/src/cli.js");
+
+interface CapturedRequest {
+ method: string;
+ url: string;
+ headers: Record;
+ body: string;
+}
+
+interface FakeSentry {
+ port: number;
+ requests: CapturedRequest[];
+ close: () => Promise;
+}
+
+/** A minimal fake Sentry ingest that records every envelope and replies `status`. */
+function startSentry(status = 200): Promise {
+ return new Promise((resolve) => {
+ const requests: CapturedRequest[] = [];
+ const server: Server = createServer((req, res) => {
+ let body = "";
+ req.on("data", (c) => (body += c));
+ req.on("end", () => {
+ requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
+ res.writeHead(status);
+ res.end();
+ });
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ resolve({ port, requests, close: () => new Promise((r) => server.close(() => r())) });
+ });
+ });
+}
+
+/** Bind then immediately release a port so a request to it is refused. */
+function reservedClosedPort(): Promise {
+ return new Promise((resolve) => {
+ const server = createServer();
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+/**
+ * Run the CLI asynchronously so the test process's event loop keeps running —
+ * the in-process fake Sentry must accept the envelope connection while the run
+ * is in flight (spawnSync would block the loop and deadlock it).
+ */
+function runCli(
+ args: string[],
+ opts: { cwd: string; env: NodeJS.ProcessEnv },
+): Promise<{ status: number; stdout: string; stderr: string }> {
+ return new Promise((resolve) => {
+ const child = spawn("node", [CLI_PATH, ...args], { cwd: opts.cwd, env: opts.env, stdio: ["ignore", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (c: string) => (stdout += c));
+ child.stderr.on("data", (c: string) => (stderr += c));
+ child.on("close", (code) => resolve({ status: code ?? 1, stdout, stderr }));
+ });
+}
+
+function baseEnv(runsRoot: string): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
+ PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
+ // Ensure a stray host DSN never leaks into the no-DSN baseline cases.
+ SENTRY_DSN: "",
+ };
+}
+
+/** A workflow whose one step fails with a nonzero exit, so the run fails. */
+const FAIL_FIXTURE = ['script boom = `echo "step output"; exit 3`', "workflow default() {", " run boom()", "}", ""].join("\n");
+
+/** A workflow that succeeds. */
+const OK_FIXTURE = ['script ok = `echo "hi"`', "workflow default() {", " run ok()", ' return "done"', "}", ""].join("\n");
+
+function sentryWarnings(stderr: string): string[] {
+ return stderr.split("\n").filter((l) => l.includes("Sentry error report"));
+}
+
+function dsn(port: number): string {
+ return `http://pubkey@127.0.0.1:${port}/7`;
+}
+
+test("jaiph run: a failed run with SENTRY_DSN delivers exactly one envelope carrying the failing step + excerpt", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-fail-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(sentry.port), SENTRY_ENVIRONMENT: "ci" },
+ });
+ assert.notEqual(result.status, 0, "the run itself fails");
+ assert.equal(sentry.requests.length, 1, "exactly one envelope POST");
+
+ const req = sentry.requests[0];
+ assert.equal(req.method, "POST");
+ assert.equal(req.url, "/api/7/envelope/", "DSN project id maps to the envelope endpoint");
+ assert.equal(req.headers["content-type"], "application/x-sentry-envelope");
+ assert.match(String(req.headers["x-sentry-auth"]), /^Sentry sentry_version=7, sentry_key=pubkey, sentry_client=jaiph\//);
+
+ const docs = req.body.split("\n");
+ assert.equal(docs.length, 3, "envelope header + item header + event");
+ const header = JSON.parse(docs[0]) as { event_id: string; sent_at: string };
+ assert.match(header.event_id, /^[0-9a-f]{32}$/);
+ assert.deepEqual(JSON.parse(docs[1]), { type: "event" });
+
+ const event = JSON.parse(docs[2]) as {
+ event_id: string;
+ level: string;
+ message: { formatted: string };
+ tags: Record;
+ extra: Record;
+ fingerprint: string[];
+ environment?: string;
+ };
+ assert.equal(event.event_id, header.event_id);
+ assert.equal(event.level, "error");
+ assert.equal(event.message.formatted, "workflow default failed (exit 3)");
+ assert.equal(event.tags["jaiph.workflow"], "default");
+ assert.equal(event.tags["jaiph.source"], "app.jh");
+ assert.equal(event.tags["jaiph.step.name"], "boom", "failing step tag present");
+ assert.equal(event.tags["jaiph.step.kind"], "script");
+ assert.equal(event.extra.failing_step_detail, "step output", "the failing step's redacted excerpt");
+ assert.ok(event.extra.run_dir && event.extra.run_dir.length > 0, "run dir pointer present");
+ assert.deepEqual(event.fingerprint, ["jaiph", "default", "boom"]);
+ assert.equal(event.environment, "ci");
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: a succeeding run with SENTRY_DSN delivers nothing", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-ok-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, OK_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(sentry.port) },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(sentry.requests.length, 0, "successful runs send nothing");
+ assert.equal(sentryWarnings(result.stderr).length, 0);
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: a failed run WITHOUT SENTRY_DSN delivers nothing", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-nodsn-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", jh], { cwd: root, env: baseEnv(join(root, ".jaiph/runs")) });
+ assert.notEqual(result.status, 0, "the run itself fails");
+ assert.equal(sentry.requests.length, 0, "no DSN → no send");
+ assert.equal(sentryWarnings(result.stderr).length, 0);
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// --- Failure isolation: exit code unchanged from the no-DSN baseline -------
+
+/** The failing run's exit code with Sentry disabled — the isolation reference. */
+async function noDsnBaselineStatus(): Promise {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-baseline-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", jh], { cwd: root, env: baseEnv(join(root, ".jaiph/runs")) });
+ return result.status;
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+}
+
+test("jaiph run: a Sentry 500 keeps the exit code at the no-DSN baseline and prints exactly one warning", async () => {
+ const baseline = await noDsnBaselineStatus();
+ const sentry = await startSentry(500);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-500-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(sentry.port) },
+ });
+ assert.equal(result.status, baseline, "telemetry is never load-bearing");
+ assert.equal(sentry.requests.length, 1, "one envelope was attempted");
+ const warns = sentryWarnings(result.stderr);
+ assert.equal(warns.length, 1, `exactly one warning line, got: ${result.stderr}`);
+ assert.match(warns[0], /HTTP 500/);
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run: a refused Sentry keeps the exit code at the no-DSN baseline and prints exactly one warning", async () => {
+ const baseline = await noDsnBaselineStatus();
+ const port = await reservedClosedPort();
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-refused-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(port) },
+ });
+ assert.equal(result.status, baseline, "a refused connection never changes the exit code");
+ const warns = sentryWarnings(result.stderr);
+ assert.equal(warns.length, 1, `exactly one warning line, got: ${result.stderr}`);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("redaction: a credential in the failing step's output reaches the event only as [REDACTED]", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-redact-"));
+ const SECRET = "supersecretvalue";
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(
+ jh,
+ ['script leak = `printf %s "$SECRET_API_KEY"; exit 1`', "workflow default() {", " run leak()", "}", ""].join("\n"),
+ );
+ const result = await runCli(["run", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SECRET_API_KEY: SECRET, SENTRY_DSN: dsn(sentry.port) },
+ });
+ assert.notEqual(result.status, 0, "the run itself fails");
+ assert.equal(sentry.requests.length, 1);
+ const body = sentry.requests[0].body;
+ assert.equal(body.includes(SECRET), false, "raw credential must never appear in the event");
+ assert.ok(body.includes("[REDACTED]"), "the redacted marker flows through from the journal");
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/telemetry/http.ts b/src/cli/telemetry/http.ts
new file mode 100644
index 00000000..cece74d0
--- /dev/null
+++ b/src/cli/telemetry/http.ts
@@ -0,0 +1,54 @@
+/**
+ * Shared timeout-guarded POST over node:http / node:https for the telemetry
+ * exporters (OTLP traces, Sentry error events). Zero dependencies — a single
+ * request is the whole transport.
+ *
+ * Resolves on any 2xx; rejects on a non-2xx status, a transport error, an
+ * invalid URL, or the timeout. Telemetry callers treat every rejection as
+ * best-effort (exactly one stderr warning) so a failed export is never
+ * load-bearing on the run.
+ */
+import http from "node:http";
+import https from "node:https";
+
+/**
+ * POST `body` to `endpoint` with the given headers and a hard timeout.
+ * `content-length` is derived from the body; the caller supplies `content-type`
+ * (and any auth) via `headers`.
+ */
+export function postWithTimeout(
+ endpoint: string,
+ body: string | Buffer,
+ headers: Record,
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let url: URL;
+ try {
+ url = new URL(endpoint);
+ } catch {
+ reject(new Error(`invalid endpoint ${endpoint}`));
+ return;
+ }
+ const lib = url.protocol === "https:" ? https : http;
+ const req = lib.request(
+ url,
+ {
+ method: "POST",
+ headers: { "content-length": Buffer.byteLength(body), ...headers },
+ },
+ (res) => {
+ res.resume();
+ res.on("end", () => {
+ const code = res.statusCode ?? 0;
+ if (code >= 200 && code < 300) resolve();
+ else reject(new Error(`server returned HTTP ${code}`));
+ });
+ },
+ );
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)));
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+}
diff --git a/src/cli/telemetry/otlp.ts b/src/cli/telemetry/otlp.ts
index fdf8d9d0..8e43d3e8 100644
--- a/src/cli/telemetry/otlp.ts
+++ b/src/cli/telemetry/otlp.ts
@@ -15,9 +15,9 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
-import http from "node:http";
-import https from "node:https";
import { VERSION } from "../../version";
+import { postWithTimeout } from "./http";
+import { reportRunFailureToSentry } from "./sentry";
/** Metadata the pure mapper needs beyond the journal lines themselves. */
export interface OtlpMeta {
@@ -296,40 +296,12 @@ function postOtlp(
payload: Record,
headers: Record,
): Promise {
- return new Promise((resolve, reject) => {
- let url: URL;
- try {
- url = new URL(endpoint);
- } catch {
- reject(new Error(`invalid endpoint ${endpoint}`));
- return;
- }
- const body = JSON.stringify(payload);
- const lib = url.protocol === "https:" ? https : http;
- const req = lib.request(
- url,
- {
- method: "POST",
- headers: {
- "content-type": "application/json",
- "content-length": Buffer.byteLength(body),
- ...headers,
- },
- },
- (res) => {
- res.resume();
- res.on("end", () => {
- const code = res.statusCode ?? 0;
- if (code >= 200 && code < 300) resolve();
- else reject(new Error(`collector returned HTTP ${code}`));
- });
- },
- );
- req.setTimeout(10_000, () => req.destroy(new Error("timed out after 10s")));
- req.on("error", reject);
- req.write(body);
- req.end();
- });
+ return postWithTimeout(
+ endpoint,
+ JSON.stringify(payload),
+ { "content-type": "application/json", ...headers },
+ 10_000,
+ );
}
/** Options for the shared post-run export hook. */
@@ -344,11 +316,20 @@ export interface ExportRunTelemetryOptions {
/**
* Single shared post-run hook, invoked wherever a run reaches terminal state on
- * the host (`jaiph run` completion, the shared workflow-call layer). Enabled iff
- * an OTLP traces endpoint is configured. Best-effort: any failure produces one
- * stderr warning and nothing else.
+ * the host (`jaiph run` completion, the shared MCP/HTTP workflow-call layer) —
+ * one choke point for every mode. Dispatches the two independent, best-effort
+ * exporters: OTLP traces (every run, when a collector is configured) and a
+ * Sentry error report (failed runs only, when `SENTRY_DSN` is set). Neither is
+ * load-bearing: a failure in either produces exactly one stderr warning and
+ * never affects the run's exit code, output, or journal.
*/
export async function exportRunTelemetry(opts: ExportRunTelemetryOptions): Promise {
+ await exportOtlpTraces(opts);
+ await reportRunFailureToSentry(opts);
+}
+
+/** OTLP/HTTP trace export half of the post-run hook. Enabled iff a traces endpoint is set. */
+async function exportOtlpTraces(opts: ExportRunTelemetryOptions): Promise {
const { runDir, workflow, exitStatus, signal, env } = opts;
const endpoint = resolveOtlpEndpoint(env);
if (!endpoint) return;
diff --git a/src/cli/telemetry/sentry.test.ts b/src/cli/telemetry/sentry.test.ts
new file mode 100644
index 00000000..60d8c2e5
--- /dev/null
+++ b/src/cli/telemetry/sentry.test.ts
@@ -0,0 +1,175 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ parseSentryDsn,
+ buildSentryEvent,
+ buildEnvelope,
+ reportRunFailureToSentry,
+ type SentryEventMeta,
+} from "./sentry";
+import { VERSION } from "../../version";
+
+const RUN_ID = "11111111-2222-3333-4444-555555555555";
+
+/** A journal covering the root, a passing step, and a failing script step. */
+function fixtureLines(): string[] {
+ const events: Record[] = [
+ { type: "WORKFLOW_START", workflow: "default", source: "/abs/path/deploy.jh", ts: "2026-07-24T10:00:00Z", run_id: RUN_ID },
+ { type: "STEP_START", func: "ok", kind: "script", name: "ok", ts: "2026-07-24T10:00:01Z", id: "R:2", run_id: RUN_ID },
+ { type: "STEP_END", func: "ok", kind: "script", name: "ok", ts: "2026-07-24T10:00:02Z", status: 0, id: "R:2", run_id: RUN_ID, out_content: "fine\n", err_content: "" },
+ { type: "STEP_START", func: "boom", kind: "script", name: "boom", ts: "2026-07-24T10:00:03Z", id: "R:3", run_id: RUN_ID },
+ { type: "STEP_END", func: "boom", kind: "script", name: "boom", ts: "2026-07-24T10:00:04Z", status: 2, id: "R:3", run_id: RUN_ID, out_content: "", err_content: "kaboom\n" },
+ { type: "WORKFLOW_END", workflow: "default", source: "/abs/path/deploy.jh", ts: "2026-07-24T10:00:05Z", run_id: RUN_ID },
+ ];
+ return events.map((e) => JSON.stringify(e));
+}
+
+const META: SentryEventMeta = {
+ workflow: "default",
+ exitStatus: 1,
+ signal: null,
+ runDir: "/abs/.jaiph/runs/2026-07-24/10-00-00-deploy",
+ release: `jaiph@${VERSION}`,
+};
+
+// --- DSN parsing -----------------------------------------------------------
+
+test("parseSentryDsn: endpoint + auth header from a well-formed DSN", () => {
+ const parsed = parseSentryDsn("https://abc123@o1.ingest.sentry.io/42");
+ assert.ok(parsed);
+ assert.equal(parsed.endpoint, "https://o1.ingest.sentry.io/api/42/envelope/");
+ assert.equal(parsed.authHeader, `Sentry sentry_version=7, sentry_key=abc123, sentry_client=jaiph/${VERSION}`);
+});
+
+test("parseSentryDsn: a non-default port is preserved in the endpoint", () => {
+ const parsed = parseSentryDsn("https://key@localhost:9000/7");
+ assert.ok(parsed);
+ assert.equal(parsed.endpoint, "https://localhost:9000/api/7/envelope/");
+});
+
+test("parseSentryDsn: malformed DSNs return null (no send)", () => {
+ assert.equal(parseSentryDsn("not a url"), null, "unparseable");
+ assert.equal(parseSentryDsn("https://o1.ingest.sentry.io/42"), null, "missing public key");
+ assert.equal(parseSentryDsn("https://key@host/"), null, "missing project id");
+ assert.equal(parseSentryDsn("https://key@host/a/42"), null, "multi-segment path is not a bare project id");
+});
+
+// --- Event composition -----------------------------------------------------
+
+test("buildSentryEvent: event_id is the run id UUID with dashes stripped", () => {
+ const event = buildSentryEvent(fixtureLines(), META);
+ assert.equal(event.event_id, "11111111222233334444555555555555");
+ assert.match(event.event_id as string, /^[0-9a-f]{32}$/);
+});
+
+test("buildSentryEvent: exit-code termination message + fingerprint + tags + redacted-source excerpt", () => {
+ const event = buildSentryEvent(fixtureLines(), META);
+ assert.equal(event.platform, "node");
+ assert.equal(event.level, "error");
+ assert.deepEqual(event.message, { formatted: "workflow default failed (exit 1)" });
+ assert.deepEqual(event.fingerprint, ["jaiph", "default", "boom"]);
+ assert.equal(event.release, `jaiph@${VERSION}`);
+ assert.equal((event as { environment?: string }).environment, undefined, "no environment key when unset");
+
+ const tags = event.tags as Record;
+ assert.equal(tags["jaiph.workflow"], "default");
+ assert.equal(tags["jaiph.source"], "deploy.jh", "source tag is the basename");
+ assert.equal(tags["jaiph.step.kind"], "script");
+ assert.equal(tags["jaiph.step.name"], "boom");
+
+ const extra = event.extra as Record;
+ assert.equal(extra.failing_step_detail, "kaboom", "the failing step's err_content excerpt");
+ assert.equal(extra.run_dir, META.runDir);
+});
+
+test("buildSentryEvent: signal termination wins over exit code in the message", () => {
+ const event = buildSentryEvent(fixtureLines(), { ...META, exitStatus: 0, signal: "SIGKILL" });
+ assert.deepEqual(event.message, { formatted: "workflow default terminated by signal SIGKILL" });
+});
+
+test("buildSentryEvent: environment is included only when set", () => {
+ const event = buildSentryEvent(fixtureLines(), { ...META, environment: "prod" });
+ assert.equal((event as { environment?: string }).environment, "prod");
+});
+
+test("buildSentryEvent: no failing step → fingerprint tail is 'unknown', no step tags", () => {
+ const lines = [
+ JSON.stringify({ type: "WORKFLOW_START", workflow: "default", source: "x.jh", ts: "2026-07-24T10:00:00Z", run_id: RUN_ID }),
+ JSON.stringify({ type: "WORKFLOW_END", workflow: "default", source: "x.jh", ts: "2026-07-24T10:00:01Z", run_id: RUN_ID }),
+ ];
+ const event = buildSentryEvent(lines, { ...META, signal: "SIGTERM", exitStatus: 0 });
+ assert.deepEqual(event.fingerprint, ["jaiph", "default", "unknown"]);
+ const tags = event.tags as Record;
+ assert.equal(tags["jaiph.step.name"], undefined);
+ assert.equal(tags["jaiph.step.kind"], undefined);
+});
+
+// --- Envelope framing ------------------------------------------------------
+
+test("buildEnvelope: three newline-separated JSON documents (header, item header, event)", () => {
+ const event = buildSentryEvent(fixtureLines(), META);
+ const envelope = buildEnvelope(event, "2026-07-24T10:00:06Z");
+ const docs = envelope.split("\n");
+ assert.equal(docs.length, 3, "exactly three lines");
+
+ const header = JSON.parse(docs[0]) as { event_id: string; sent_at: string };
+ assert.equal(header.event_id, "11111111222233334444555555555555", "envelope header event_id matches run id hex");
+ assert.equal(header.sent_at, "2026-07-24T10:00:06Z");
+
+ const itemHeader = JSON.parse(docs[1]) as { type: string };
+ assert.deepEqual(itemHeader, { type: "event" });
+
+ const body = JSON.parse(docs[2]) as { event_id: string };
+ assert.equal(body.event_id, header.event_id, "item event_id matches the envelope header");
+});
+
+// --- Enablement / failure gates (no network) -------------------------------
+
+async function captureStderr(fn: () => Promise): Promise {
+ const captured: string[] = [];
+ const original = process.stderr.write.bind(process.stderr);
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
+ captured.push(s);
+ return true;
+ };
+ try {
+ await fn();
+ } finally {
+ (process.stderr as unknown as { write: typeof original }).write = original;
+ }
+ return captured;
+}
+
+test("reportRunFailureToSentry: a successful run sends nothing and warns nothing", async () => {
+ const out = await captureStderr(() =>
+ reportRunFailureToSentry({
+ runDir: "/nonexistent",
+ workflow: "default",
+ exitStatus: 0,
+ signal: null,
+ env: { SENTRY_DSN: "https://key@host/1" },
+ }),
+ );
+ assert.equal(out.length, 0);
+});
+
+test("reportRunFailureToSentry: a failed run without SENTRY_DSN sends nothing and warns nothing", async () => {
+ const out = await captureStderr(() =>
+ reportRunFailureToSentry({ runDir: "/nonexistent", workflow: "default", exitStatus: 1, signal: null, env: {} }),
+ );
+ assert.equal(out.length, 0);
+});
+
+test("reportRunFailureToSentry: a failed run with a malformed DSN warns exactly once and does not send", async () => {
+ const out = await captureStderr(() =>
+ reportRunFailureToSentry({
+ runDir: "/nonexistent",
+ workflow: "default",
+ exitStatus: 1,
+ signal: null,
+ env: { SENTRY_DSN: "https://host/1" },
+ }),
+ );
+ assert.equal(out.length, 1, "exactly one warning line");
+ assert.match(out[0], /malformed SENTRY_DSN/);
+});
diff --git a/src/cli/telemetry/sentry.ts b/src/cli/telemetry/sentry.ts
new file mode 100644
index 00000000..314a79d9
--- /dev/null
+++ b/src/cli/telemetry/sentry.ts
@@ -0,0 +1,210 @@
+/**
+ * Sentry error reporting for a failed run.
+ *
+ * Fires host-side, after a run reaches an *unsuccessful* terminal state, from
+ * the shared post-run telemetry hook (`exportRunTelemetry` in `otlp.ts`) — never
+ * inside the runtime/emitter and never across the sandbox boundary. Successful
+ * runs send nothing. Enabled iff `SENTRY_DSN` is set.
+ *
+ * The event is built entirely from the run's `run_summary.jsonl`, which is
+ * already credential-redacted and host-visible in every sandbox mode, so a
+ * secret in step output reaches Sentry only as `[REDACTED]`. The raw per-step
+ * capture files are never read.
+ *
+ * Zero runtime dependencies: a single hand-rolled Sentry *envelope* POST over
+ * the shared timeout-guarded HTTP helper. Never load-bearing — an unreachable
+ * or erroring Sentry, a malformed DSN, or a timeout produces exactly one stderr
+ * warning and never touches the run's exit code, output, or journal. No retries.
+ */
+import { existsSync, readFileSync } from "node:fs";
+import { basename, join } from "node:path";
+import { VERSION } from "../../version";
+import { postWithTimeout } from "./http";
+import type { ExportRunTelemetryOptions } from "./otlp";
+
+/** 10 s hard cap on the envelope POST, matching the OTLP exporter. */
+const SEND_TIMEOUT_MS = 10_000;
+
+type JournalEvent = Record;
+
+/** A parsed DSN: where to POST and the auth header to send. */
+export interface SentryDsn {
+ endpoint: string;
+ authHeader: string;
+}
+
+/** Metadata the pure event builder needs beyond the journal lines themselves. */
+export interface SentryEventMeta {
+ /** Root workflow symbol (`default` for `jaiph run`, the tool symbol for MCP). */
+ workflow: string;
+ /** Child exit status; part of the failure message when no signal. */
+ exitStatus: number;
+ /** Terminating signal, when the run was killed; wins over exit code in the message. */
+ signal: string | null;
+ /** Absolute host run directory, surfaced as `extra.run_dir`. */
+ runDir?: string;
+ /** `release` field — `SENTRY_RELEASE` or `jaiph@`. */
+ release: string;
+ /** `environment` field — set only when `SENTRY_ENVIRONMENT` is present. */
+ environment?: string;
+}
+
+function asString(v: unknown): string {
+ return typeof v === "string" ? v : "";
+}
+
+/**
+ * Parse a Sentry DSN `https://@/` into the envelope
+ * endpoint `https:///api//envelope/` and the `X-Sentry-Auth`
+ * header value. Returns null for any malformed DSN (missing key, unparseable
+ * URL, or a non-single-segment project id) — the caller warns once and sends
+ * nothing.
+ */
+export function parseSentryDsn(dsn: string): SentryDsn | null {
+ let url: URL;
+ try {
+ url = new URL(dsn.trim());
+ } catch {
+ return null;
+ }
+ const key = url.username;
+ const projectId = url.pathname.replace(/^\/+/, "").replace(/\/+$/, "");
+ if (!key || !url.hostname || !projectId || projectId.includes("/")) return null;
+ const portSuffix = url.port ? `:${url.port}` : "";
+ const endpoint = `${url.protocol}//${url.hostname}${portSuffix}/api/${projectId}/envelope/`;
+ const authHeader = `Sentry sentry_version=7, sentry_key=${key}, sentry_client=jaiph/${VERSION}`;
+ return { endpoint, authHeader };
+}
+
+function parseJournal(lines: string[]): JournalEvent[] {
+ const events: JournalEvent[] = [];
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) continue;
+ try {
+ events.push(JSON.parse(trimmed) as JournalEvent);
+ } catch {
+ // A malformed line never blocks the report — telemetry is best-effort.
+ }
+ }
+ return events;
+}
+
+/**
+ * Map journal lines + run metadata to a Sentry event payload.
+ *
+ * Pure: `event_id` is the run id UUID with dashes stripped; the failing step is
+ * the first `STEP_END` with a nonzero status; its redacted `err_content` (else
+ * `out_content`) excerpt becomes `extra.failing_step_detail`. `fingerprint`
+ * groups re-occurrences per workflow + failing step.
+ */
+export function buildSentryEvent(lines: string[], meta: SentryEventMeta): Record {
+ const events = parseJournal(lines);
+ const runId = asString(events.find((e) => typeof e.run_id === "string")?.run_id);
+ const eventId = runId.replace(/-/g, "");
+ const timestamp = asString(events[events.length - 1]?.ts) || asString(events[0]?.ts);
+ const wf =
+ events.find((e) => e.type === "WORKFLOW_START") ?? events.find((e) => e.type === "WORKFLOW_END");
+ const source = asString(wf?.source);
+
+ // First failed step (nonzero STEP_END status) — the run's proximate failure.
+ const failing = events.find(
+ (e) => e.type === "STEP_END" && typeof e.status === "number" && e.status !== 0,
+ );
+ const stepKind = failing ? asString(failing.kind) : "";
+ const stepName = failing ? asString(failing.name) : "";
+ const detail = failing
+ ? asString(failing.err_content).trim() || asString(failing.out_content).trim()
+ : "";
+
+ const message = meta.signal
+ ? `workflow ${meta.workflow} terminated by signal ${meta.signal}`
+ : `workflow ${meta.workflow} failed (exit ${meta.exitStatus})`;
+
+ const tags: Record = { "jaiph.workflow": meta.workflow };
+ if (source) tags["jaiph.source"] = basename(source);
+ if (stepKind) tags["jaiph.step.kind"] = stepKind;
+ if (stepName) tags["jaiph.step.name"] = stepName;
+
+ const extra: Record = {};
+ if (detail) extra.failing_step_detail = detail;
+ if (meta.runDir) extra.run_dir = meta.runDir;
+
+ const event: Record = {
+ event_id: eventId,
+ timestamp,
+ platform: "node",
+ level: "error",
+ message: { formatted: message },
+ tags,
+ extra,
+ fingerprint: ["jaiph", meta.workflow, stepName || "unknown"],
+ release: meta.release,
+ };
+ if (meta.environment) event.environment = meta.environment;
+ return event;
+}
+
+/**
+ * Frame a Sentry envelope: an envelope header line (`event_id` + `sent_at`), an
+ * item header line (`type: event`), and the event JSON — three newline-separated
+ * JSON documents.
+ */
+export function buildEnvelope(event: Record, sentAt: string): string {
+ const header = JSON.stringify({ event_id: event.event_id, sent_at: sentAt });
+ const itemHeader = JSON.stringify({ type: "event" });
+ const body = JSON.stringify(event);
+ return `${header}\n${itemHeader}\n${body}`;
+}
+
+/**
+ * Report a failed run to Sentry from the shared post-run hook. No-op on success
+ * (only nonzero exit / a signal reports) and when `SENTRY_DSN` is unset. Any
+ * transport/HTTP/DSN failure produces exactly one stderr warning and nothing
+ * else — the run's exit code and output are untouched.
+ */
+export async function reportRunFailureToSentry(opts: ExportRunTelemetryOptions): Promise {
+ const { runDir, workflow, exitStatus, signal, env } = opts;
+
+ // Fire only on an unsuccessful terminal state — successful runs send nothing.
+ const runFailed = exitStatus !== 0 || signal != null;
+ if (!runFailed) return;
+
+ const dsnRaw = env.SENTRY_DSN?.trim();
+ if (!dsnRaw) return; // disabled
+
+ const dsn = parseSentryDsn(dsnRaw);
+ if (!dsn) {
+ process.stderr.write("jaiph: Sentry error report skipped — malformed SENTRY_DSN\n");
+ return;
+ }
+
+ if (!runDir) return;
+ const summaryFile = join(runDir, "run_summary.jsonl");
+ if (!existsSync(summaryFile)) return;
+ let lines: string[];
+ try {
+ lines = readFileSync(summaryFile, "utf8").split("\n");
+ } catch {
+ return;
+ }
+ if (lines.every((l) => l.trim().length === 0)) return;
+
+ const release = env.SENTRY_RELEASE?.trim() || `jaiph@${VERSION}`;
+ const environment = env.SENTRY_ENVIRONMENT?.trim() || undefined;
+ const event = buildSentryEvent(lines, { workflow, exitStatus, signal, runDir, release, environment });
+ const envelope = buildEnvelope(event, new Date().toISOString());
+
+ try {
+ await postWithTimeout(
+ dsn.endpoint,
+ envelope,
+ { "content-type": "application/x-sentry-envelope", "x-sentry-auth": dsn.authHeader },
+ SEND_TIMEOUT_MS,
+ );
+ } catch (err) {
+ process.stderr.write(
+ `jaiph: Sentry error report failed — ${err instanceof Error ? err.message : String(err)}\n`,
+ );
+ }
+}
From 1854524e0de36e5be0b867a4ff47707786f32a52 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 10:11:54 +0200
Subject: [PATCH 05/24] Feat: support running the runtime image standalone in
docker/k8s
The ghcr.io/jaiphlang/jaiph-runtime image already bundles jaiph and the
claude/cursor/codex backends, but it was only usable as a sandbox rootfs
orchestrated by a host jaiph process. Running it directly failed because
jaiph defaults to Docker sandboxing on Linux and there is no daemon
inside the container.
Bake ENV JAIPH_UNSAFE=true into runtime/Dockerfile so the container acts
as its own sandbox in host mode; the inner `jaiph run --raw` invocation
never launches Docker, so host-orchestrated sandbox behavior is
unchanged (pinned by test). When jaiph would launch Docker but the CLI
is missing and a container indicator (/.dockerenv or /run/.containerenv)
is present, the error now tells the user to set JAIPH_UNSAFE=true.
Add docs/deploy.md (one-shot docker run, CI note, Kubernetes) with a
real docs/deploy/k8s.yaml manifest and an explicit no-jaiph-sandbox
security posture, linked from README, sandboxing, and setup docs. Add a
gated standalone-image e2e smoke test and a kubectl dry-run manifest
gate in CI.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/ci.yml | 15 ++++
CHANGELOG.md | 4 +
QUEUE.md | 31 -------
README.md | 3 +-
docs/_layouts/docs.html | 1 +
docs/deploy.md | 84 +++++++++++++++++++
docs/deploy/k8s.yaml | 125 +++++++++++++++++++++++++++++
docs/env-vars.md | 2 +-
docs/index.html | 6 ++
docs/sandboxing.md | 1 +
docs/setup.md | 1 +
e2e/test_all.sh | 1 +
e2e/tests/148_standalone_image.sh | 54 +++++++++++++
runtime/Dockerfile | 10 +++
src/runtime/docker-inplace.test.ts | 46 +++++++++++
src/runtime/docker-inplace.ts | 13 +++
src/runtime/docker.test.ts | 49 +++++++++++
src/runtime/docker.ts | 31 +++++++
18 files changed, 444 insertions(+), 33 deletions(-)
create mode 100644 docs/deploy.md
create mode 100644 docs/deploy/k8s.yaml
create mode 100755 e2e/tests/148_standalone_image.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f779f39..615110da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,21 @@ jobs:
VERSION="$(node -p "require('./package.json').version")"
git ls-remote --exit-code https://github.com/jaiphlang/jaiph.git "refs/tags/v${VERSION}"
+ k8s-manifest:
+ name: Validate Kubernetes deploy manifest
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # `kubectl apply --dry-run=client` still needs an API server for resource
+ # discovery (RESTMapper), so provision a throwaway kind cluster for it.
+ - name: Create kind cluster
+ uses: helm/kind-action@v1
+
+ - name: Dry-run apply the standalone deploy manifest
+ run: kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+
e2e:
name: E2E (${{ matrix.os }}, ${{ matrix.label }})
runs-on: ${{ matrix.os }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16ff218f..0c39e5ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,12 @@
- **Report failed runs to Sentry:** a run that terminates *unsuccessfully* (nonzero exit or a signal) is now pushed to a Sentry error tracker as one error event — enabled by the standard `SENTRY_DSN` — so operators running workflows on a schedule or as a service get alerting and grouping without scraping run directories. The event carries the workflow, the failing step, a credential-redacted output excerpt, and a run-dir pointer, and groups re-occurrences per workflow + failing step. Like trace export it is host-side, end-of-run, adds no runtime dependencies, and is never load-bearing: an unreachable or erroring Sentry produces exactly one stderr warning and leaves the run's exit code and output untouched. Successful runs send nothing.
+- **Run the runtime image standalone:** the published `ghcr.io/jaiphlang/jaiph-runtime` image now bakes `JAIPH_UNSAFE=true`, so you can run it directly — `docker run --rm -e ANTHROPIC_API_KEY -v "$PWD":/work -w /work ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh`, or as a Kubernetes pod — with no host jaiph process and no Docker daemon inside the container. The story is "put credentials + jaiph files and run it": inside the image the container *is* the sandbox, so host-mode execution is the correct default and there is no jaiph-managed isolation (workspace content policy is the operator's responsibility). Host-orchestrated sandbox behavior is unchanged. A new [Deploy the runtime image standalone](docs/deploy.md) how-to and an apply-ready `docs/deploy/k8s.yaml` manifest cover `docker run`, CI, and Kubernetes, including the no-jaiph-sandbox security posture.
+
## All changes
+- **Feat — standalone runtime image: run jaiph in Docker/Kubernetes without a host orchestrator:** The published `ghcr.io/jaiphlang/jaiph-runtime` image (`runtime/Dockerfile`) already contained `jaiph`, the claude/cursor/codex backends, and a full toolchain, but it was only ever used as a *sandbox rootfs orchestrated by a host jaiph process*; running it directly (`docker run … jaiph run flow.jh`, or as a k8s pod) failed because jaiph defaults to Docker sandboxing on Linux and there is no Docker daemon inside the container. The image now **bakes `ENV JAIPH_UNSAFE=true`** (with a rationale comment: inside the image the container *is* the sandbox, so host-mode execution is correct and the interactive `--unsafe` confirmation is impossible/meaningless in an unattended pod), so the "put credentials + jaiph files and run it" story works on every build. **Host-orchestrated sandbox behavior is unchanged:** no `ENTRYPOINT` is added and `WORKDIR` is untouched (the host passes an explicit command argv that a prefix would corrupt), and the container-inner invocation the host uses is `jaiph run --raw`, which by contract never launches Docker regardless of `JAIPH_UNSAFE` — the full Docker e2e suite stays green with the ENV present. New `src/runtime/docker.ts` seams back the standalone story: an injectable `_containerIndicator.present()` (true when `/.dockerenv` — Docker — or `/run/.containerenv` — Podman / CRI — exists) and `isRunningInContainer()`; `checkDockerAvailable()` now, when Docker is unavailable **and** a container indicator is present, throws `E_DOCKER_NOT_FOUND … jaiph is running inside a container already. Set JAIPH_UNSAFE=true to run in host mode (the container is the sandbox). See https://jaiph.org/deploy` (covering users of derived images without the baked ENV) — with no indicator the original "Install Docker …" error is unchanged. `confirmUnsafeRun` (`src/runtime/docker-inplace.ts`) now, inside a container, proceeds with a one-line stderr notice (`running host-only inside a container (the container is the sandbox)`) instead of prompting or aborting `E_UNSAFE_NO_CONFIRM` — mirroring the win32 host-only override — which is what lets an unattended pod run. No new `JAIPH_*` env vars. Tests: `src/runtime/docker.test.ts` (indicator present → the container guidance with `JAIPH_UNSAFE=true`; indicator absent → the install-Docker error verbatim), `src/runtime/docker-inplace.test.ts` (in-container non-TTY without auto-confirm proceeds without opening a prompt and prints the notice; on a host the same conditions still throw `E_UNSAFE_NO_CONFIRM`), and a gated `e2e/tests/148_standalone_image.sh` (wired into `e2e/test_all.sh`) that runs the built image standalone on a fixture `hello.jh` with **no** `-e JAIPH_UNSAFE` and no Docker daemon inside, asserting the `return` value round-trips to `return_value.txt` and exit `0` — proving host mode comes purely from the baked ENV. CI: a new `k8s-manifest` job in `.github/workflows/ci.yml` provisions a throwaway kind cluster and runs `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` on every build. Docs: new [Deploy the runtime image standalone](docs/deploy.md) how-to (one-shot `docker run` with the claude/cursor/codex credential variants, a CI note pointing at the pattern `nightly-engineer.yml` uses, Kubernetes, and a plainly-stated **no-jaiph-sandbox security posture** — isolation is the container/pod boundary and workspace content policy is the operator's responsibility), an apply-ready `docs/deploy/k8s.yaml` (Deployment + Secret for backend credentials and `JAIPH_SERVE_TOKEN` + Service, `jaiph serve --host 0.0.0.0` with `/healthz` liveness/readiness probes, tag-pinning / TLS-via-ingress / resource-request guidance), links from README, [Sandboxing](docs/sandboxing.md), and [Install & switch versions](docs/setup.md), an updated `JAIPH_UNSAFE` row in [Environment variables](docs/env-vars.md) noting the image bakes it, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23` — "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … it can't depend on a local machine + docker".)
+
- **Feat — OTLP trace export: one span tree per run, zero dependencies:** A completed run now exports as an OpenTelemetry trace to any OTLP/HTTP collector (a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog), so operators running workflows in CI or as a service get a per-run latency breakdown and failure signal in a standard observability stack instead of only the local run dir. Export is **host-side and end-of-run**, in the new `src/cli/telemetry/otlp.ts` (zero runtime dependencies — a `node:https`/`node:http` request is the whole transport): after a run reaches terminal state the CLI reads *that run's* `run_summary.jsonl` and posts one trace. Nothing new crosses the sandbox boundary and no `OTEL_*` variable is forwarded into the container — the journal is complete (it carries the `WORKFLOW_*`/`PROMPT_*` records the live stderr stream omits), already credential-redacted by `RuntimeEventEmitter`, and host-visible in every sandbox mode (the run dir is a host mount); runs are minutes long, so end-of-run batching is the normal OTLP pattern anyway. The pure `runSummaryToOtlp(lines, meta)` maps journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`: **trace id** = the run-id UUID with dashes stripped (32 hex, per the spec's JSON mapping), **span id** = first 16 hex of `sha256()` — deterministic, so re-exporting a run yields byte-identical ids. One **root span** `workflow ` per run (`WORKFLOW_START`→`WORKFLOW_END` timestamps, falling back to first/last event `ts`; status `ERROR` when the run exits nonzero or a signal killed it, else `OK`; each `LOGERR`/`LOGWARN` becomes a span event on it); one **step span** per `STEP_START`/`STEP_END` pair matched by event `id`, parented via `parent_id` (root when null), `kind: SPAN_KIND_INTERNAL`, attributes `jaiph.step.{kind,func,name,seq,depth,status,elapsed_ms}` plus the redacted `jaiph.step.out`/`jaiph.step.err` captures, span status `ERROR` on a nonzero step status; **prompt spans** as children of their `step_id` with `jaiph.prompt.{backend,model,status}`. A `STEP_START` with no matching `STEP_END` (a crash) closes at the last event's `ts` with status `ERROR`; ISO `ts` → `timeUnixNano` strings. Resource attributes: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), the `OTEL_RESOURCE_ATTRIBUTES` pairs, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`. **Enablement is standard OTEL env — no new `JAIPH_*`:** export runs iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set (traces-specific wins when both are); `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) is applied; only `http/json` is spoken — any other `OTEL_EXPORTER_OTLP_PROTOCOL` (e.g. `grpc`) warns on stderr and skips rather than mis-speak a protocol. A single shared hook `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})` fires at every host terminal state — `jaiph run` completion (`reportResult` in `src/cli/commands/run.ts`) and the shared workflow-call layer (`src/cli/exec/call.ts`, covering every MCP `tools/call` and `jaiph serve` invocation) — one choke point covering host, Docker snapshot, and inplace modes. **Telemetry is never load-bearing:** an unreachable or erroring collector (or the 10 s timeout) produces exactly one stderr warning line and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/otlp.test.ts` (fixture journals — trace id from run id, step parenting per `parent_id`, prompt child of `step_id` with backend/model, failed step → span status 2, nonzero exit → root status 2, ISO→nano strings, unmatched `STEP_START` closes ERROR at the last event time, deterministic ids across two invocations; endpoint-resolution precedence, header parsing, `http/json`-only guard) and `integration/otlp-export.test.ts` (a fake collector receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set, and nothing with no OTEL env; a `500` and a connection-refused collector each leave exit `0` with exactly one stderr warning; a shared-call/MCP invocation triggers exactly one export per call; a credential echoed in step output appears in the payload only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: new [Export traces to an OTLP collector](observability.md) how-to, a "Telemetry variables" section in [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — "OTEL + Sentry configurable would be the easiest way".)
- **Feat — Sentry error reporting on failed runs, zero dependencies:** A run that reaches an *unsuccessful* terminal state (nonzero exit or a terminating signal) is now reported to a Sentry error tracker as one **error event**, so teams running jaiph workflows on schedules or as a service (CI, Kubernetes) get failures pushed to their tracker with enough context to triage — instead of only a local run dir and a nonzero exit code. The new `src/cli/telemetry/sentry.ts` (zero runtime dependencies — a hand-rolled Sentry **envelope** POST is the whole transport) fires from the **same host-side, end-of-run choke point** that exports OTLP traces: the shared `exportRunTelemetry` hook (`src/cli/telemetry/otlp.ts`) now dispatches two independent, best-effort exporters — OTLP traces (every run, when a collector is set) and the Sentry report (**failed runs only**, when `SENTRY_DSN` is set) — so `jaiph run` completion and every workflow invoked through `jaiph mcp` / `jaiph serve` are all covered by one path. **Successful runs send nothing.** The timeout-guarded POST was extracted into a shared `src/cli/telemetry/http.ts` (`postWithTimeout`, `node:http`/`node:https`) that both exporters reuse. **Enablement is a standard Sentry DSN — no new `JAIPH_*`:** reporting is on iff `SENTRY_DSN` is set; the pure `parseSentryDsn` maps `https://@/` to the envelope endpoint `https:///api//envelope/` and the `X-Sentry-Auth: Sentry sentry_version=7, sentry_key=, sentry_client=jaiph/` header (a non-default port is preserved), and a malformed DSN produces exactly one stderr warning and no send. The pure `buildSentryEvent(lines, meta)` maps the run's `run_summary.jsonl` lines + `{workflow, exitStatus, signal, runDir, release, environment}` to the event, sourced **entirely from `run_summary.jsonl`** — already credential-redacted by `RuntimeEventEmitter`, never the raw `.out`/`.err` captures — so a secret in step output reaches Sentry only as `[REDACTED]`: **`event_id`** = the run-id UUID with dashes stripped; `timestamp`; `platform: node`; `level: error`; **`message.formatted`** = `workflow failed (exit N)`, or `terminated by signal S` (the signal wins over the exit code); **`tags`** `jaiph.workflow`, `jaiph.source` (source file basename), and the first failing `STEP_END`'s `jaiph.step.kind` / `jaiph.step.name` when known; **`extra`** `failing_step_detail` (that step's redacted `err_content`/`out_content` excerpt) and `run_dir`; **`fingerprint`** `["jaiph", , ]`, so re-occurrences group per workflow + failing step; **`release`** from `SENTRY_RELEASE` or `jaiph@`, and **`environment`** from `SENTRY_ENVIRONMENT` when set. `buildEnvelope` frames the body as three newline-separated JSON documents — an envelope header (`event_id` + `sent_at`), an item header (`type: event`), and the event JSON. **Never load-bearing:** an unreachable or erroring Sentry, a non-2xx status, or the 10 s timeout produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/sentry.test.ts` (DSN endpoint + auth header, non-default port preserved, malformed → null/no-send; `event_id` matches the run-id hex; exit-code vs signal message, fingerprint, tags, and redacted-source excerpt; environment included only when set; no-failing-step → `unknown` fingerprint tail and no step tags; envelope framing as three JSON documents; a successful run and a failed run without a DSN send and warn nothing, a malformed DSN warns exactly once) and `integration/sentry-export.test.ts` (a fake Sentry HTTP server — a failing `jaiph run` with `SENTRY_DSN` delivers exactly one envelope carrying the failing step tag and redacted excerpt; a succeeding run and a failing run **without** `SENTRY_DSN` deliver nothing; a `500` and a connection-refused Sentry each leave the exit code at the no-DSN baseline with exactly one stderr warning; a credential echoed in the failing step's output reaches the delivered event only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: a new [Report failed runs to Sentry](observability.md#report-failed-runs-to-sentry) section in [Export traces to an OTLP collector](observability.md), `SENTRY_DSN` / `SENTRY_ENVIRONMENT` / `SENTRY_RELEASE` rows in the telemetry section of [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — the Sentry half of "OTEL + Sentry configurable".)
diff --git a/QUEUE.md b/QUEUE.md
index cc375b37..64f890c3 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -13,34 +13,3 @@ Process rules:
7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
***
-
-## Feat: standalone runtime image — run jaiph in docker/k8s without a host orchestrator #dev-ready
-
-**Source:** Deployability feedback (2026-07-23): "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … that would allow anyone to run jaiph in docker/kubernetes" — and "it can't depend on a local machine + docker".
-
-**Problem:** `ghcr.io/jaiphlang/jaiph-runtime` (`runtime/Dockerfile`) already contains jaiph plus the claude/cursor/codex backends and a full toolchain, but it is only ever used as a sandbox rootfs *orchestrated by a host jaiph process*. Running it directly (`docker run … jaiph run flow.jh`, or as a k8s pod) fails the wrong way: jaiph defaults to Docker sandboxing on Linux and there is no Docker daemon inside the container. There is also zero documentation for deploying the image as the runner itself, even though `.github/workflows/nightly-engineer.yml` proves jaiph works headless on a bare Linux box.
-
-**Required behavior:**
-
-* **Bake `ENV JAIPH_UNSAFE=true` into `runtime/Dockerfile`** with a comment stating the rationale: *inside this image, the container is the sandbox* — host-mode execution is the correct default, and the interactive `--unsafe` confirmation is impossible/meaningless in an unattended pod. Verify and pin by test that this does not change host-orchestrated sandbox behavior: the container-inner invocation is `jaiph run --raw`, which by contract never launches Docker regardless of `JAIPH_UNSAFE` (the existing Docker e2e suite must stay green with the ENV present).
-* Do **not** add an `ENTRYPOINT` and do not change `WORKDIR`: the host-orchestrated sandbox passes an explicit command argv, and an entrypoint prefix would corrupt it. Standalone usage spells the full command (`docker run … ghcr.io/jaiphlang/jaiph-runtime jaiph run /work/flow.jh`).
-* When jaiph would launch Docker but the CLI is unavailable **and** a container indicator is present (`/.dockerenv` or `/run/.containerenv`), the error message must say precisely what to do: running inside a container already — set `JAIPH_UNSAFE=true` (host mode; the container is the sandbox). This covers users of derived images without the baked ENV.
-* New `docs/deploy.md` (how-to, linked from README, `docs/sandboxing.md`, and `docs/setup.md`):
- * one-shot: `docker run --rm -e ANTHROPIC_API_KEY -v "$PWD":/work -w /work ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh` (and the cursor/codex credential variants — `CURSOR_API_KEY`, `OPENAI_API_KEY`);
- * CI usage note pointing at the pattern `nightly-engineer.yml` already uses;
- * Kubernetes: a real manifest file `docs/deploy/k8s.yaml` (Deployment + Secret for backend credentials + Service; liveness/readiness probes; image tag pinning note; TLS-via-ingress note; resource-request guidance — agent workloads are CPU/memory hungry). If `jaiph serve` exists in the codebase when this task is implemented, the manifest runs `jaiph serve --host 0.0.0.0` with `JAIPH_SERVE_TOKEN` from the Secret and probes `/healthz`; otherwise the manifest demonstrates a long-lived workflow runner and the doc says the HTTP surface is queued.
- * A plainly-stated security paragraph: in standalone mode there is **no** jaiph-managed sandbox — isolation is whatever the deployment provides (the container/pod boundary), and workspace content policy (gitignored secrets etc.) is the operator's responsibility, unlike the host-orchestrated snapshot sandbox.
-* CI smoke test (job in `.github/workflows/ci.yml` on the built image, or a gated e2e in `e2e/tests/` where the image is available): run the image standalone with `docker run` executing a fixture workflow that writes a `return` value; assert the value round-trips and exit code 0 — proving the "put credentials + jaiph files and run it" story on every build.
-* Manifest validity gate: `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` (kubectl is on GitHub runners) runs in CI and passes.
-* No new `JAIPH_*` env vars expected; any introduced must get `docs/env-vars.md` rows (parity lint). Update the `JAIPH_UNSAFE` row to mention the image bakes it.
-
-Acceptance:
-
-* CI (or gated e2e) smoke test: `docker run --rm -v :/work -w /work jaiph run hello.jh` exits 0 and produces the expected return value, with no Docker daemon available inside the container.
-* The full existing Docker-sandbox e2e suite passes against the image with the baked `ENV JAIPH_UNSAFE=true` (host-orchestrated behavior unchanged).
-* Unit test for the container-detection error path: docker unavailable + `/.dockerenv` present (injectable check) yields the "container is the sandbox → set JAIPH_UNSAFE=true" message; without the indicator, the existing error is unchanged.
-* `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` passes in CI.
-* `docs/deploy.md` exists, is linked from README + `docs/sandboxing.md` + `docs/setup.md`, and documents the no-jaiph-sandbox security posture explicitly.
-* `npm test` and `npm run test:e2e` pass.
-
-***
diff --git a/README.md b/README.md
index 3d5f4739..8917d949 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md)
-> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md), [Export traces to an OTLP collector](docs/observability.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
+> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md), [Export traces to an OTLP collector](docs/observability.md), [Deploy the runtime image standalone](docs/deploy.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md).
---
@@ -30,6 +30,7 @@
- **HTTP API** — `jaiph serve ./tools.jh` serves the same workflows over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs ([Serve workflows over HTTP](docs/serve.md)).
- **OpenTelemetry** — set the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and each run exports one span tree (workflow → steps → prompts) to any OTLP collector — Grafana Tempo, Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, zero new dependencies, never load-bearing ([Export traces to an OTLP collector](docs/observability.md)).
- **Sentry error reporting** — set the standard `SENTRY_DSN` and every failed run (nonzero exit or a signal) is pushed to Sentry as one error event — workflow, failing step, a redacted output excerpt, and a run-dir pointer — so operators get alerting and grouping without scraping run dirs. Host-side, redacted, zero new dependencies, never load-bearing; successful runs send nothing ([Report failed runs to Sentry](docs/observability.md#report-failed-runs-to-sentry)).
+- **Standalone deployment** — the published `ghcr.io/jaiphlang/jaiph-runtime` image bakes `JAIPH_UNSAFE=true`, so `docker run … jaiph run flow.jh` (or a Kubernetes pod) runs workflows directly — put credentials plus `.jh` files and go, no host jaiph process and no Docker daemon inside the container. Here the container/pod boundary *is* the sandbox — there is no jaiph-managed isolation ([Deploy the runtime image standalone](docs/deploy.md)).
## Core components
diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html
index 01546c26..26602410 100644
--- a/docs/_layouts/docs.html
+++ b/docs/_layouts/docs.html
@@ -57,6 +57,7 @@
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 00000000..09a3861d
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,84 @@
+---
+title: Deploy the runtime image standalone
+permalink: /how-to/deploy
+diataxis: how-to
+---
+
+# Deploy Jaiph as a standalone runtime image
+
+The published runtime image — `ghcr.io/jaiphlang/jaiph-runtime` (built from `runtime/Dockerfile`) — already contains `jaiph`, the `claude` / `cursor` / `codex` agent backends, and a full engineering toolchain. This guide runs that image **as the runner itself**: `docker run` on any Linux box, in CI, or as a Kubernetes pod. You supply credentials and `.jh` files; the container does the rest. No host `jaiph` process and no host Docker daemon are involved.
+
+For the host-orchestrated Docker *sandbox* — where a host `jaiph run` clones your workspace and launches this same image as a disposable rootfs — see [Run in a Docker sandbox](sandbox-run.md) and [Sandboxing](sandboxing.md) instead. This page is the opposite direction: the image *is* the deployment.
+
+## Security posture: there is no jaiph-managed sandbox
+
+Read this before deploying. In standalone mode **Jaiph does not create a sandbox**. Isolation is whatever your deployment already provides — the container or pod boundary — and nothing more:
+
+- The image bakes `ENV JAIPH_UNSAFE=true`, so `jaiph run` executes **on the host** (which, here, is the container). This is correct and deliberate: inside the container the container *is* the sandbox, and jaiph must not try to launch a nested Docker daemon (there is none). It also means the [snapshot / gitignore filtering](sandboxing.md#snapshot-content) that the host-orchestrated Docker sandbox performs does **not** happen. Every file you mount is visible to scripts and agent backends verbatim — gitignored secrets included.
+- **Workspace content policy is the operator's responsibility.** Unlike the host-orchestrated snapshot sandbox (which never even copies a gitignored `.env` into the container), a standalone container reads exactly what you mount. Do not mount secrets you would not hand to the agent, and treat everything under the mounted workspace as disclosed to the run.
+- Container/pod hardening (read-only root FS, dropped capabilities, network policy, non-root UID, resource limits) is yours to configure at the deployment layer. Jaiph adds none of it in standalone mode.
+
+Contrast: under the host-orchestrated sandbox ([Sandboxing](sandboxing.md)) Jaiph drops caps, filters the workspace to a git-defined snapshot, and enforces an env allowlist. Standalone mode has none of those — the boundary is the container runtime you chose.
+
+## One-shot: `docker run`
+
+Mount your working directory at `/work`, set it as the working directory, and spell the full command (the image sets **no `ENTRYPOINT`**, so `jaiph run …` is the container command verbatim):
+
+```bash
+# claude backend (Anthropic)
+docker run --rm -e ANTHROPIC_API_KEY -v "$PWD":/work -w /work \
+ ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh
+```
+
+The credential env var depends on the backend the entry file selects:
+
+```bash
+# cursor backend
+docker run --rm -e CURSOR_API_KEY -v "$PWD":/work -w /work \
+ ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh
+
+# codex backend (OpenAI HTTP API)
+docker run --rm -e OPENAI_API_KEY -v "$PWD":/work -w /work \
+ ghcr.io/jaiphlang/jaiph-runtime jaiph run flow.jh
+```
+
+`-e ANTHROPIC_API_KEY` (no `=value`) forwards the value from your shell environment. A workflow with no `prompt` step needs no credential at all. Run artifacts land under `/work/.jaiph/runs/` — which, because `/work` is your bind-mounted directory, persist on the host after the container exits.
+
+Pin the tag (or a `@sha256:` digest) for reproducibility: `ghcr.io/jaiphlang/jaiph-runtime:`.
+
+## In CI
+
+The image is not required in CI. Jaiph already runs headless on a standard Linux runner without it — `.github/workflows/nightly-engineer.yml` installs jaiph via `docs/install-from-local.sh` (which also builds `runtime/Dockerfile` and registers it as the sandbox image), installs the agent CLI, and runs a workflow unattended in the normal [Docker sandbox](sandboxing.md). A GitHub-hosted Linux runner is itself a VM with a Docker daemon, so that path keeps the host-orchestrated sandbox and needs no published image.
+
+Reach for this image when you want the whole toolchain and all three backends preinstalled with nothing to build. Because the container has no nested Docker daemon and the image bakes `JAIPH_UNSAFE=true`, it runs host-mode (the container is the sandbox), and the `docker run` one-shot above drops straight into any CI step:
+
+```yaml
+- name: Run workflow
+ run: |
+ docker run --rm -e ANTHROPIC_API_KEY -v "$PWD":/work -w /work \
+ ghcr.io/jaiphlang/jaiph-runtime: jaiph run flow.jh
+```
+
+## Kubernetes
+
+A complete, apply-ready manifest lives at [`docs/deploy/k8s.yaml`](https://github.com/jaiphlang/jaiph/blob/main/docs/deploy/k8s.yaml). It defines a **Deployment**, a **Secret** for backend credentials (and the serve token), and a **Service**. Validate it without a cluster:
+
+```bash
+kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+```
+
+The manifest runs `jaiph serve --host 0.0.0.0` as a long-lived HTTP runner (see [Serve workflows over HTTP](serve.md)) with `JAIPH_SERVE_TOKEN` sourced from the Secret, and liveness/readiness probes on `GET /healthz` (which stays open — no bearer token required). Highlights, all reflected in the file:
+
+- **Image tag pinning.** The manifest ships `:nightly` with an inline note to pin a released tag or a `@sha256:` digest for production — never track a moving tag.
+- **TLS via ingress.** `jaiph serve` speaks plain HTTP; the Service stays `ClusterIP` and you terminate TLS at an Ingress / gateway (cert-manager, a cloud LB, or a mesh) in front of it. Do not expose the token-guarded API to the internet without TLS.
+- **Resource requests.** Agent workloads are CPU- and memory-hungry — they spawn backend CLIs plus build/test toolchains. The manifest requests `1` CPU / `2Gi` and limits `2` CPU / `4Gi` as a starting point; tune to your workflows.
+- **Auth.** Binding `0.0.0.0` without `JAIPH_SERVE_TOKEN` is a startup error by design, so the Secret is mandatory. Every `/v1/*` request then requires `Authorization: Bearer `.
+
+The same security posture applies: the pod runs in host mode (`JAIPH_UNSAFE=true` is baked), so **isolation is the pod boundary you configure** — there is no jaiph-managed sandbox inside.
+
+## Related
+
+- [Sandboxing](sandboxing.md) — the host-orchestrated Docker sandbox model this mode deliberately opts out of.
+- [Serve workflows over HTTP](serve.md) — the `jaiph serve` API the Kubernetes manifest exposes.
+- [Run in a Docker sandbox](sandbox-run.md) — the other direction: a host `jaiph` orchestrating this image as a disposable sandbox.
+- [Environment variables](env-vars.md) — `JAIPH_UNSAFE`, `JAIPH_SERVE_TOKEN`, and the rest.
diff --git a/docs/deploy/k8s.yaml b/docs/deploy/k8s.yaml
new file mode 100644
index 00000000..1e024a1e
--- /dev/null
+++ b/docs/deploy/k8s.yaml
@@ -0,0 +1,125 @@
+# Standalone Jaiph on Kubernetes — long-lived HTTP runner.
+#
+# This runs the published runtime image directly as a Deployment: the image
+# already contains `jaiph`, the claude/cursor/codex backends, and a full
+# toolchain, and it bakes `JAIPH_UNSAFE=true`, so the pod runs workflows in
+# host mode — the pod boundary IS the sandbox (there is no jaiph-managed Docker
+# sandbox in standalone mode). See docs/deploy.md for the security posture.
+#
+# Validate without a cluster:
+# kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+#
+# The workflow file is supplied here via a ConfigMap mounted at /work for a
+# self-contained example; a real deployment usually bakes workflows into a
+# derived image or mounts them from a volume / git-sync sidecar instead.
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: jaiph-credentials
+ labels:
+ app: jaiph-runner
+type: Opaque
+stringData:
+ # HTTP API bearer token. `jaiph serve` refuses to bind a non-loopback host
+ # (0.0.0.0) unless this is set — every /v1/* request then requires
+ # `Authorization: Bearer `. Replace with a real secret (e.g. via a
+ # sealed-secret / external-secrets operator) before applying to a cluster.
+ JAIPH_SERVE_TOKEN: "replace-me-with-a-long-random-token"
+ # Backend credentials — set only the one(s) your workflows use.
+ # claude -> ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)
+ # cursor -> CURSOR_API_KEY
+ # codex -> OPENAI_API_KEY
+ ANTHROPIC_API_KEY: "replace-me"
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: jaiph-workflows
+ labels:
+ app: jaiph-runner
+data:
+ tools.jh: |
+ # health — trivial workflow proving the runner is live.
+ export workflow health() {
+ return "ok"
+ }
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: jaiph-runner
+ labels:
+ app: jaiph-runner
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: jaiph-runner
+ template:
+ metadata:
+ labels:
+ app: jaiph-runner
+ spec:
+ containers:
+ - name: jaiph
+ # Pin the image tag (or, better, a @sha256 digest) for reproducible
+ # rollouts — do NOT track a moving tag like :nightly in production.
+ image: ghcr.io/jaiphlang/jaiph-runtime:nightly
+ # The image sets no ENTRYPOINT, so spell the full command.
+ command: ["jaiph", "serve", "--host", "0.0.0.0", "/work/tools.jh"]
+ ports:
+ - name: http
+ containerPort: 5247
+ envFrom:
+ - secretRef:
+ name: jaiph-credentials
+ volumeMounts:
+ - name: workflows
+ mountPath: /work
+ # /healthz stays open (no bearer token required) and is served as soon
+ # as the HTTP listener is up.
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ # Agent workloads are CPU- and memory-hungry (they spawn backend CLIs
+ # plus build/test toolchains). Size generously and tune to your flows.
+ resources:
+ requests:
+ cpu: "1"
+ memory: 2Gi
+ limits:
+ cpu: "2"
+ memory: 4Gi
+ volumes:
+ - name: workflows
+ configMap:
+ name: jaiph-workflows
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: jaiph-runner
+ labels:
+ app: jaiph-runner
+spec:
+ selector:
+ app: jaiph-runner
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
+ type: ClusterIP
+# TLS: keep the Service ClusterIP and terminate TLS at an Ingress / gateway in
+# front of it (cert-manager, a cloud LB, or a service mesh). `jaiph serve`
+# speaks plain HTTP; do not expose it to the internet without TLS termination
+# and the JAIPH_SERVE_TOKEN bearer check above.
diff --git a/docs/env-vars.md b/docs/env-vars.md
index c74ada87..94b6e83b 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -85,7 +85,7 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_SOURCE_FILE` | internal | string (basename) | entry-file basename | — | Used to name run directories. |
| `JAIPH_STDLIB` | host | path | — | — | Removed from the product. Stripped from the launched env. |
| `JAIPH_TEST_MODE` | runtime | bool (exact `"1"`) | `false` | — | Set by `jaiph test` so the runtime skips production-only branches (e.g. file-mode normalization). |
-| `JAIPH_UNSAFE` | host | bool (`true` only) | `false` | — | Disable Docker for this run; execute on the host with **no sandbox** (entire filesystem and host environment visible to scripts and agent backends). `--unsafe` is the `jaiph run` flag form. When this turns Docker off while it would otherwise be on, `jaiph run` requires consent: a TTY warning + `Continue? [y/N]` (default no), or `JAIPH_INPLACE_YES` / `--yes` non-interactively (else `E_UNSAFE_NO_CONFIRM`). No prompt when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, Windows host-only override) or on `jaiph run --raw`. |
+| `JAIPH_UNSAFE` | host | bool (`true` only) | `false` | — | Disable Docker for this run; execute on the host with **no sandbox** (entire filesystem and host environment visible to scripts and agent backends). `--unsafe` is the `jaiph run` flag form. When this turns Docker off while it would otherwise be on, `jaiph run` requires consent: a TTY warning + `Continue? [y/N]` (default no), or `JAIPH_INPLACE_YES` / `--yes` non-interactively (else `E_UNSAFE_NO_CONFIRM`). No prompt when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, Windows host-only override) or on `jaiph run --raw`. The `ghcr.io/jaiphlang/jaiph-runtime` image **bakes `JAIPH_UNSAFE=true`** so it can run standalone (`docker run … jaiph run flow.jh`, or as a k8s pod) — inside the image the container is the sandbox; see [Deploy](deploy.md). |
| `JAIPH_WORKSPACE` | host, runtime | path | autodetected | — | Workspace root. Inside Docker the host CLI overrides this to `/jaiph/workspace`. |
diff --git a/docs/index.html b/docs/index.html
index 8128cc5a..8b915bc9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -580,6 +580,12 @@
Runtime
pointer — so operators get alerting and grouping without scraping run dirs. Host-side,
redacted, with zero new dependencies, never load-bearing; successful runs send nothing.
See Report failed runs to Sentry.
+
Standalone deployment. The published
+ ghcr.io/jaiphlang/jaiph-runtime image bakes JAIPH_UNSAFE=true, so
+ docker run … jaiph run flow.jh (or a Kubernetes pod) runs workflows directly —
+ put credentials plus .jh files and go, with no host Jaiph process and no Docker daemon
+ inside the container. Here the container/pod boundary is the sandbox; there is no
+ jaiph-managed isolation. See Deploy the runtime image standalone.
Samples
Jaiph source code is built mostly with real Jaiph workflows. The
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index 18a6299b..f8c41b52 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -263,4 +263,5 @@ Custom images are supported via `JAIPH_DOCKER_IMAGE` / `runtime.docker_image`; t
- [Architecture — Docker runtime helper](architecture.md#core-components) — the spawn, mount, and event-stream wiring.
- [Architecture — Channels and hooks in context](architecture.md#channels-and-hooks-in-context) — why hooks run on the host even for containerized runs.
+- [Deploy the runtime image standalone](deploy.md) — running the runtime image directly (`docker run` / Kubernetes) where the container *is* the sandbox and there is no jaiph-managed isolation.
- [Why Jaiph](why-jaiph.md) — the design context that puts the sandbox into the broader picture.
diff --git a/docs/setup.md b/docs/setup.md
index 0a26a3b3..63666231 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -91,4 +91,5 @@ For maintainer setup, see [Contributing — Release signing](contributing.md#rel
## Related
- [Architecture — Distribution: Node vs Bun standalone](architecture.md#distribution-node-vs-bun-standalone) — what the installer downloads and why the binary is self-contained.
+- [Deploy the runtime image standalone](deploy.md) — skip installing entirely: run the prebuilt `ghcr.io/jaiphlang/jaiph-runtime` image directly via `docker run` or Kubernetes.
- [Why Jaiph](why-jaiph.md) — the design context behind the single-binary distribution.
diff --git a/e2e/test_all.sh b/e2e/test_all.sh
index 88fe6cf1..f2ebda79 100755
--- a/e2e/test_all.sh
+++ b/e2e/test_all.sh
@@ -105,6 +105,7 @@ TEST_SCRIPTS=(
"e2e/tests/141_mcp_docker_sandbox.sh"
"e2e/tests/147_serve_http_api.sh"
"e2e/tests/146_trusted_envs.sh"
+ "e2e/tests/148_standalone_image.sh"
"e2e/tests/210_standalone_binary.sh"
)
diff --git a/e2e/tests/148_standalone_image.sh b/e2e/tests/148_standalone_image.sh
new file mode 100755
index 00000000..e6e6d7c2
--- /dev/null
+++ b/e2e/tests/148_standalone_image.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT_DIR}/e2e/lib/common.sh"
+trap e2e::cleanup EXIT
+
+e2e::prepare_test_env "standalone_image"
+TEST_DIR="${JAIPH_E2E_TEST_DIR}"
+
+if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
+ e2e::section "standalone runtime image (skipped — Docker unavailable)"
+ e2e::skip "Docker is not available, skipping standalone image smoke test"
+ exit 0
+fi
+
+if ! e2e::ensure_docker_test_image; then
+ e2e::section "standalone runtime image (skipped — test image build failed)"
+ e2e::skip "Could not build local Docker test image"
+ exit 0
+fi
+
+e2e::section "standalone image — put credentials + jaiph files and run it"
+
+# A workflow that returns a value. No prompt/agent step, so no credentials are
+# needed; the point is that `jaiph run` works with no host orchestrator and no
+# Docker daemon inside the container, purely off the image's baked JAIPH_UNSAFE.
+e2e::file "hello.jh" <<'EOF'
+workflow default() {
+ return "standalone-ok"
+}
+EOF
+
+# Run the image standalone, exactly as the docs one-shot does. We do NOT pass
+# -e JAIPH_UNSAFE: host mode must come from the ENV baked into the image. There
+# is no Docker daemon inside the container, so if the baked ENV were missing the
+# inner run would abort with E_DOCKER_NOT_FOUND instead of exiting 0.
+# --user matches the host UID so the bind-mounted run artifacts are writable and
+# readable back by the test harness.
+docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ -v "${TEST_DIR}":/work -w /work \
+ "${E2E_DOCKER_TEST_IMAGE}" \
+ jaiph run hello.jh >/dev/null
+
+# The return value must round-trip to the durable return_value.txt artifact.
+return_file="$(find "${TEST_DIR}/.jaiph/runs" -name return_value.txt | head -n 1)"
+if [[ -z "${return_file}" ]]; then
+ echo "FAIL: no return_value.txt produced by standalone run" >&2
+ exit 1
+fi
+return_value="$(cat "${return_file}")"
+e2e::assert_equals "${return_value}" "standalone-ok" "standalone return value round-trip"
diff --git a/runtime/Dockerfile b/runtime/Dockerfile
index 98bcb127..d5dfabda 100644
--- a/runtime/Dockerfile
+++ b/runtime/Dockerfile
@@ -243,4 +243,14 @@ USER root
RUN find /home/jaiph -type d -exec chmod 1777 {} +
USER jaiph
+# Default to host-mode execution: inside this image, the container IS the
+# sandbox, so jaiph must not try to launch a nested Docker daemon (there is
+# none), and the interactive `--unsafe` confirmation is impossible/meaningless
+# in an unattended pod. This only affects the image run *standalone*
+# (`docker run … jaiph run flow.jh`, or as a k8s pod); when the image is used
+# as a host-orchestrated sandbox rootfs the inner invocation is
+# `jaiph run --raw`, which never launches Docker regardless of JAIPH_UNSAFE, so
+# host-orchestrated sandbox behavior is unchanged. See docs/deploy.md.
+ENV JAIPH_UNSAFE=true
+
WORKDIR /jaiph/workspace
diff --git a/src/runtime/docker-inplace.test.ts b/src/runtime/docker-inplace.test.ts
index 0f7945af..0ece1c59 100644
--- a/src/runtime/docker-inplace.test.ts
+++ b/src/runtime/docker-inplace.test.ts
@@ -4,7 +4,10 @@ import {
formatInplaceWarning,
formatUnsafeWarning,
UNSAFE_RUN_LOGWARN_MESSAGE,
+ confirmUnsafeRun,
+ _inplacePrompt,
} from "./docker-inplace";
+import { _containerIndicator } from "./docker";
test("formatInplaceWarning: lean scope copy with workspace path", () => {
const ws = "/Users/me/projects/jaiph";
@@ -29,3 +32,46 @@ test("UNSAFE_RUN_LOGWARN_MESSAGE: present-tense runtime warning", () => {
assert.match(UNSAFE_RUN_LOGWARN_MESSAGE, /You are running/);
assert.match(UNSAFE_RUN_LOGWARN_MESSAGE, /unsafe mode with no sandboxing/);
});
+
+test("confirmUnsafeRun: inside a container, proceeds without prompting (non-TTY, no auto-confirm)", async () => {
+ const origPresent = _containerIndicator.present;
+ const origAsk = _inplacePrompt.ask;
+ const origWrite = process.stderr.write.bind(process.stderr);
+ let asked = false;
+ let notice = "";
+ try {
+ _containerIndicator.present = () => true;
+ _inplacePrompt.ask = async () => {
+ asked = true;
+ return false;
+ };
+ (process.stderr as any).write = (chunk: string) => {
+ notice += chunk;
+ return true;
+ };
+ // No JAIPH_INPLACE_YES, stdin not a TTY: on a host this would throw
+ // E_UNSAFE_NO_CONFIRM. Inside a container it must proceed instead.
+ const proceed = await confirmUnsafeRun("/work", {}, false);
+ assert.equal(proceed, true, "container run must proceed unattended");
+ assert.equal(asked, false, "must not open an interactive prompt in a container");
+ assert.match(notice, /the container is the sandbox/);
+ } finally {
+ _containerIndicator.present = origPresent;
+ _inplacePrompt.ask = origAsk;
+ (process.stderr as any).write = origWrite;
+ }
+});
+
+test("confirmUnsafeRun: not in a container + non-TTY + no auto-confirm still throws E_UNSAFE_NO_CONFIRM", async () => {
+ const origPresent = _containerIndicator.present;
+ try {
+ _containerIndicator.present = () => false;
+ await assert.rejects(
+ () => confirmUnsafeRun("/work", {}, false),
+ /E_UNSAFE_NO_CONFIRM/,
+ "host non-TTY unsafe run without consent must still abort",
+ );
+ } finally {
+ _containerIndicator.present = origPresent;
+ }
+});
diff --git a/src/runtime/docker-inplace.ts b/src/runtime/docker-inplace.ts
index bf20410c..4bc2851f 100644
--- a/src/runtime/docker-inplace.ts
+++ b/src/runtime/docker-inplace.ts
@@ -1,4 +1,5 @@
import { createInterface } from "node:readline";
+import { isRunningInContainer } from "./docker";
/** Runtime tree warning emitted at the start of every consented unsafe host-only run. */
export const UNSAFE_RUN_LOGWARN_MESSAGE =
@@ -106,6 +107,18 @@ export async function confirmUnsafeRun(
if (isAutoConfirmed(env)) {
return true;
}
+ // Inside a container the container IS the sandbox: host-only execution is the
+ // correct posture, the "full access to your machine" warning is meaningless
+ // (there is no host to endanger), and an unattended pod has no TTY to confirm.
+ // Proceed with a one-line notice instead of prompting or aborting — mirrors
+ // the win32 host-only override. This is what lets the runtime image, which
+ // bakes JAIPH_UNSAFE=true, run standalone (docker run … jaiph run flow.jh).
+ if (isRunningInContainer()) {
+ process.stderr.write(
+ "jaiph: running host-only inside a container (the container is the sandbox).\n",
+ );
+ return true;
+ }
if (!isTTY) {
throw new Error(
"E_UNSAFE_NO_CONFIRM jaiph unsafe mode (host-only, no sandbox) requires interactive confirmation, " +
diff --git a/src/runtime/docker.test.ts b/src/runtime/docker.test.ts
index 060b58eb..36eccc28 100644
--- a/src/runtime/docker.test.ts
+++ b/src/runtime/docker.test.ts
@@ -25,6 +25,8 @@ import {
stopDockerRunOnSignal,
spawnDockerProcess,
withDockerExitGuard,
+ checkDockerAvailable,
+ _containerIndicator,
_dockerExec,
_dockerSpawn,
_cpSpawn,
@@ -277,6 +279,53 @@ test("checkDockerAvailable: E_DOCKER_NOT_FOUND message mentions JAIPH_UNSAFE", (
);
});
+test("checkDockerAvailable: container indicator present → 'container is the sandbox' guidance", () => {
+ const origExec = _dockerExec.run;
+ const origPresent = _containerIndicator.present;
+ try {
+ // Docker unavailable + a container indicator present (as inside the runtime
+ // image run standalone): the error must steer the user to JAIPH_UNSAFE=true.
+ _dockerExec.run = () => {
+ throw new Error("docker not found");
+ };
+ _containerIndicator.present = () => true;
+ assert.throws(
+ () => checkDockerAvailable(),
+ (err: Error) =>
+ /E_DOCKER_NOT_FOUND/.test(err.message) &&
+ /running inside a container already/.test(err.message) &&
+ /JAIPH_UNSAFE=true/.test(err.message) &&
+ /the container is the sandbox/.test(err.message),
+ "container-detection path must tell the user to set JAIPH_UNSAFE=true",
+ );
+ } finally {
+ _dockerExec.run = origExec;
+ _containerIndicator.present = origPresent;
+ }
+});
+
+test("checkDockerAvailable: no container indicator → existing error unchanged", () => {
+ const origExec = _dockerExec.run;
+ const origPresent = _containerIndicator.present;
+ try {
+ _dockerExec.run = () => {
+ throw new Error("docker not found");
+ };
+ _containerIndicator.present = () => false;
+ assert.throws(
+ () => checkDockerAvailable(),
+ (err: Error) =>
+ /E_DOCKER_NOT_FOUND docker is not available\. Install Docker/.test(err.message) &&
+ /JAIPH_UNSAFE=true to run on the host/.test(err.message) &&
+ !/running inside a container already/.test(err.message),
+ "without a container indicator the original install-Docker error must be unchanged",
+ );
+ } finally {
+ _dockerExec.run = origExec;
+ _containerIndicator.present = origPresent;
+ }
+});
+
// ---------------------------------------------------------------------------
// buildDockerArgs: snapshot mode (host clone bound rw at /jaiph/workspace)
// ---------------------------------------------------------------------------
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index 1e198a4b..11abe1f3 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -220,10 +220,41 @@ export const _uidDetect = {
// Docker availability
// ---------------------------------------------------------------------------
+/**
+ * Test seam for the container-indicator probe. `/.dockerenv` (Docker) and
+ * `/run/.containerenv` (Podman / CRI runtimes) mark that the current process is
+ * already running inside a container. Injectable so the container-detection
+ * error path can be unit-tested without a real container.
+ */
+export const _containerIndicator = {
+ present(): boolean {
+ return existsSync("/.dockerenv") || existsSync("/run/.containerenv");
+ },
+};
+
+/**
+ * True when jaiph is running inside a container (Docker `/.dockerenv` or a
+ * Podman / CRI `/run/.containerenv` marker). In that case the container itself
+ * is the sandbox boundary.
+ */
+export function isRunningInContainer(): boolean {
+ return _containerIndicator.present();
+}
+
export function checkDockerAvailable(): void {
try {
_dockerExec.run(["info"], { stdio: "ignore", timeout: 10_000 });
} catch {
+ // Docker is unavailable. If a container indicator is present we are almost
+ // certainly running the runtime image directly (docker run / a k8s pod), so
+ // there is no nested Docker daemon to reach — point the user at the standalone
+ // story rather than telling them to install Docker.
+ if (isRunningInContainer()) {
+ throw new Error(
+ "E_DOCKER_NOT_FOUND docker is not available, and jaiph is running inside a container already. " +
+ "Set JAIPH_UNSAFE=true to run in host mode (the container is the sandbox). See https://jaiph.org/deploy for details.",
+ );
+ }
throw new Error("E_DOCKER_NOT_FOUND docker is not available. Install Docker and ensure the daemon is running, or set JAIPH_UNSAFE=true to run on the host (no sandbox).");
}
}
From 0504ea1cea06ed2b95a2fe586136e94855e3a28f Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 12:16:23 +0200
Subject: [PATCH 06/24] Chore: queue service hardening work for Fable
Record the deployment review as executable tasks and use Fable for their implementation.
Co-authored-by: Cursor
---
.jaiph/engineer.jh | 2 +-
QUEUE.md | 180 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 181 insertions(+), 1 deletion(-)
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index bf137a52..fc604ecc 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -233,7 +233,7 @@ workflow classify_role(task) {
workflow implement(task, role_name) {
config {
- agent.model = "opus"
+ agent.model = "fable"
}
run task_text_has_header(task) catch (err) {
diff --git a/QUEUE.md b/QUEUE.md
index 64f890c3..cb144034 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -13,3 +13,183 @@ Process rules:
7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
***
+
+## Redact credentials from every workflow result surface #dev-ready
+
+`src/cli/exec/call.ts` builds failed-call text from live stderr events, while the durable journal is credential-redacted. As a result, `jaiph serve` can return a secret in `result_text` and `jaiph mcp` can return it in a tool error even though events, OTLP, and Sentry redact the same value.
+
+Scope:
+
+- Build failed-step details and other returned output from redacted data, using one redaction boundary shared by HTTP and MCP.
+- Cover `failedStep.detail`, raw stderr/stdout, and collected log messages; do not rely only on the durable event endpoint.
+- Keep successful return values unchanged: workflow return values are intentional API output, not diagnostic capture.
+
+Acceptance:
+
+- An integration workflow that prints a credential-like env value and fails never exposes the value through `?wait=true`, `GET /v1/runs/{id}`, or an MCP tool result.
+- The same test proves `[REDACTED]` is returned where diagnostic context is retained.
+- Existing event-journal, OTLP, and Sentry redaction tests still pass.
+
+## Keep MCP generations alive while calls are in flight #dev-ready
+
+`jaiph mcp` handles tool calls concurrently, but hot reload immediately deletes the previous generation's scripts directory. A call started just before reload can still need those scripts. Signal shutdown also removes the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounts generations; MCP needs the same lifecycle guarantee.
+
+Scope:
+
+- Share or mirror the generation lease/refcount model for MCP and HTTP.
+- Delete a superseded generation only after its last call settles.
+- On stdin close, drain active calls before cleanup. On SIGINT/SIGTERM, use a documented drain-then-cancel policy and stop Docker containers as well as child processes.
+
+Acceptance:
+
+- A slow MCP call started before a source reload completes successfully after the new generation is active.
+- Concurrent calls spanning multiple reloads use the generation captured at call start and leave no generation directories after all calls settle.
+- Shutdown tests prove no active call reads deleted scripts and no child process/container is orphaned.
+
+## Make the Kubernetes example runnable and hardened by default #dev-ready
+
+The current manifest mounts the workflow ConfigMap read-only at `/work`, while standalone host mode writes runs under `/work/.jaiph/runs`; the example is schema-valid but cannot complete a real run. It also ships publicly known placeholder secrets and omits the pod hardening that `docs/deploy.md` says the operator must supply.
+
+Scope:
+
+- Mount a writable `emptyDir` or PVC for `JAIPH_RUNS_DIR` without making workflow sources writable.
+- Remove applyable placeholder credentials from the base manifest; document and validate an external Secret contract.
+- Add `runAsNonRoot`, fixed UID/GID, `allowPrivilegeEscalation: false`, dropped capabilities, a runtime-default seccomp profile, and disabled service-account token mounting.
+- Provide writable mounts only for paths genuinely required by Jaiph and agent CLIs; keep the remaining filesystem read-only where feasible.
+- Add optional OTLP and Sentry env wiring examples without embedding credentials.
+
+Acceptance:
+
+- A Kind-based test applies the manifest, invokes the HTTP workflow with auth, observes a successful run, and reads its journal from the writable runs volume.
+- The pod runs as non-root and the test fails if privilege escalation, capabilities, or the default service-account token are reintroduced.
+- `kubectl apply --dry-run=client` remains a fast schema check, but is not the only deployment test.
+
+## Bound HTTP service memory, output, and run retention #dev-ready
+
+The concurrency cap limits active children but not process memory. The in-memory run map grows forever, completed `result_text` stays resident forever, and raw child stdout/stderr/log accumulation is not bounded. An authenticated caller can exhaust a long-lived service.
+
+Scope:
+
+- Add explicit byte caps for collected stdout, stderr, logs, and public `result_text`, with a visible truncation marker.
+- Add configurable completed-run retention by count and age; active runs must never be evicted.
+- Paginate `GET /v1/runs` with a bounded default and maximum page size.
+- Keep durable journals/artifacts independent from in-memory eviction and document their disk-retention responsibility.
+
+Acceptance:
+
+- Runs producing output beyond every cap keep the process within a tested memory bound and return deterministic truncation markers.
+- More completed runs than the configured limit evicts only the oldest terminal records.
+- Run listing cannot return an unbounded response, and pagination order is stable.
+- Concurrency, cancellation, SSE, and artifact tests continue to pass.
+
+## Make HTTP request, event, and artifact I/O scale with bytes transferred #dev-ready
+
+The HTTP layer still performs avoidable whole-resource work: an aborted request body can leave `readBody` pending, SSE repeatedly scans historical run directories until `run_dir` is known, each SSE poll rereads the whole journal, and artifact downloads load the complete file into memory.
+
+Scope:
+
+- Settle request-body reads on `aborted`/premature `close` and stop all associated work.
+- Cache `run_id -> run_dir` as soon as it is first resolved so a live SSE stream does not repeatedly scan the runs tree.
+- Follow journals with an open file descriptor and byte offset instead of rereading prior bytes on every poll.
+- Stream artifacts with backpressure and an explicit configurable size policy; do not buffer the complete artifact.
+
+Acceptance:
+
+- Destroying a request mid-upload settles its handler promptly and leaves no request or run slot occupied.
+- With many historical runs, one live SSE connection performs at most one full run-directory resolution scan.
+- Instrumented SSE tests prove bytes before the current offset are not reread.
+- A multi-gigabyte sparse artifact can be served with bounded process memory, and disconnecting the client closes the file stream.
+
+## Use one execution-policy contract across run, serve, and MCP #dev-ready
+
+The shared parser accepts flags for every command, but commands silently ignore options they do not destructure. For example, `jaiph serve --unsafe` and `jaiph mcp --inplace` parse successfully but do not apply those flags. `run` exposes sandbox flags while long-lived modes require env vars, creating different mental models for the same execution engine.
+
+Scope:
+
+- Define shared options for `--workspace`, repeatable `--env`, `--inplace`, `--unsafe`, and `--yes`; support them consistently in `run`, `serve`, and `mcp`.
+- Define and document one precedence order across CLI flags, `JAIPH_*` env vars, and workflow runtime metadata.
+- Reject mutually exclusive posture and all command-specific unsupported flags as usage errors instead of treating or ignoring them as positionals.
+- Resolve and print the effective sandbox posture once at server startup, then apply it to every call. Preserve the documented standalone-container exception where the container/pod is the sandbox.
+- Decide and implement one lifecycle-hook contract for all three modes; mode differences must be explicit rather than caused by separate execution paths.
+- Keep display-only options such as `--raw` and transport options such as `--host`/`--port` command-specific.
+
+Acceptance:
+
+- A table-driven integration suite runs the same sandbox/env cases through all three modes and observes the same effective child env and filesystem isolation.
+- `serve --unsafe`, `serve --inplace`, `mcp --unsafe`, and `mcp --inplace` have tested effects; conflicting flags fail before spawning.
+- Flags belonging to another command fail with a clear usage error.
+- Hook tests prove the documented contract for direct, HTTP, and MCP invocations.
+- Command help and env-var reference describe the same precedence and consent rules.
+
+## Give every run mode complete, bounded telemetry behavior #dev-ready
+
+Normal `jaiph run`, HTTP calls, and MCP calls invoke the shared OTLP/Sentry hook, but `jaiph run --raw` bypasses it while the docs claim every run is covered. OTLP and Sentry are awaited sequentially, so unavailable backends can hold a completed run and a service concurrency slot for up to 20 seconds despite being described as non-load-bearing.
+
+Scope:
+
+- Export telemetry for a user-invoked standalone `jaiph run --raw` without double-exporting the inner raw process used by host-orchestrated Docker.
+- Run independent exporters concurrently under one configurable total flush budget.
+- In long-lived HTTP/MCP processes, mark the run terminal and release execution concurrency before best-effort delivery; track delivery failures through bounded logging/metrics.
+- Keep telemetry operator-side: `OTEL_*` and `SENTRY_*` values must not enter workflow sandboxes unless explicitly passed as workflow env.
+
+Acceptance:
+
+- Standard run, standalone raw run, MCP, and HTTP each produce one OTLP trace; each failed mode produces one Sentry event.
+- A Docker-sandboxed run still exports exactly once.
+- Unreachable telemetry endpoints cannot delay an HTTP/MCP terminal result or occupied execution slot beyond a small tested bound.
+- Export failures never change workflow output or exit status.
+
+## Expose HTTP API and network MCP from one service process #dev-ready
+
+Today `jaiph serve` is HTTP-only and `jaiph mcp` is stdio-only. The same file can be exposed by two separate processes, but there is no single deployed company service that serves both protocols or a Kubernetes-addressable MCP endpoint.
+
+Scope:
+
+- Add standards-compliant MCP Streamable HTTP to `jaiph serve` on a documented path while retaining the existing REST/OpenAPI API.
+- Reuse the same tool generation, hot reload, auth boundary, concurrency limiter, cancellation, sandbox/env policy, run IDs, artifacts, and telemetry for both transports.
+- Keep `jaiph mcp` stdio for local clients; do not duplicate workflow execution logic.
+- Document reverse-proxy requirements for streaming, timeouts, TLS, and authentication.
+
+Acceptance:
+
+- One process serves REST and MCP clients concurrently against the same workflow generation.
+- MCP calls appear in the same run inspection API and obey the same concurrency and cancellation rules.
+- Bearer auth protects both protocol surfaces on non-loopback binds.
+- Docker and Kind integration tests exercise both transports from outside the container/pod.
+
+## Make service runs restart-safe and retry-safe #dev-ready
+
+Run artifacts are durable, but HTTP run discovery is only an in-memory map. Restarting the process makes completed run IDs unreachable, loses in-flight state, and makes client retries start duplicate expensive workflows. This is a single-process developer server, not yet a reliable company service contract.
+
+Scope:
+
+- Persist the public run record beside the journal and reconstruct terminal runs on startup.
+- Reconcile interrupted `running` records after process death into an explicit terminal state.
+- Support an idempotency key on run creation, scoped to workflow plus authenticated principal, with conflict detection for a reused key and different arguments.
+- Define the supported deployment topology explicitly. If multi-replica operation is not implemented, fail/document it as single-replica and keep the Kubernetes example at one replica.
+
+Acceptance:
+
+- After restart, list/get/events/artifacts work for pre-restart terminal runs.
+- A run interrupted by process death is not reported as permanently running.
+- Repeating an identical create request with the same idempotency key returns the original run; changed arguments produce a conflict and never spawn.
+- Recovery and idempotency survive a real process restart integration test.
+
+## Add production authentication, authorization, and audit identity #dev-ready
+
+`JAIPH_SERVE_TOKEN` is a useful fail-closed shared-secret gate, but it provides no user identity, revocation, per-action authorization, or attribution. That is insufficient when multiple company users can invoke arbitrary engineering workflows.
+
+Scope:
+
+- Keep the static bearer token as an explicit single-operator mode.
+- Add a standard OIDC/JWT mode configured by issuer, audience, and JWKS discovery; use a maintained JWT library rather than custom cryptography.
+- Authorize separate invoke, inspect/artifact, and cancel capabilities.
+- Attach authenticated principal and request/correlation ID to run metadata, logs, OTLP resources, and Sentry tags without putting tokens or claims containing secrets into journals.
+- Make exposure of `/docs` and `/openapi.json` configurable; keep health probes free of credentials and sensitive details.
+
+Acceptance:
+
+- Valid, expired, wrong-audience, wrong-issuer, unknown-key, and insufficient-scope tokens are covered by integration tests.
+- A principal cannot inspect or cancel runs outside its authorization policy.
+- Audit records identify who invoked and cancelled a run while never containing bearer tokens.
+- Static-token mode remains tested and clearly documented as single-operator, not multi-tenant authentication.
From a165cd5d9f22b7dffba89ee0e3ce9e8c22042aa1 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 12:34:55 +0200
Subject: [PATCH 07/24] Fix: redact credentials from every returned workflow
result
composeResult in src/cli/exec/call.ts built a failed call's text from
the run's live captured output, so jaiph serve (result_text) and jaiph
mcp (tool errors) could return a secret verbatim even though the event
journal, OTLP, and Sentry all redacted it. The credential rule is now a
single shared helper, redactCredentials, extracted from
RuntimeEventEmitter into src/runtime/kernel/redact.ts and imported by
both the journal writer and composeResult. composeResult redacts the
fully assembled failure text once, so failed-step detail, raw
stderr/stdout, and collected log messages all pass the same boundary,
and the Docker --env passthrough is merged back in for redaction.
Successful return values stay verbatim as intentional API output.
Covered by src/cli/exec/call.test.ts and integration tests that a
failing workflow echoing a credential never leaks it through ?wait=true,
GET /v1/runs/{id}, or an MCP tool result.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
QUEUE.md | 16 -----
docs/architecture.md | 4 +-
docs/mcp.md | 2 +-
docs/serve.md | 2 +-
integration/mcp-server.test.ts | 32 +++++++++
integration/serve-server.test.ts | 35 ++++++++++
src/cli/exec/call.test.ts | 75 +++++++++++++++++++++
src/cli/exec/call.ts | 40 +++++++++--
src/runtime/kernel/redact.ts | 24 +++++++
src/runtime/kernel/runtime-event-emitter.ts | 18 +----
11 files changed, 207 insertions(+), 43 deletions(-)
create mode 100644 src/cli/exec/call.test.ts
create mode 100644 src/runtime/kernel/redact.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c39e5ff..0f7a3481 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@
## All changes
+- **Fix — redact credentials from every returned workflow result, not just the durable journal:** `jaiph serve`'s `result_text` and a `jaiph mcp` tool error could return a secret that events, OTLP, and Sentry all redacted. The failure text was built in `composeResult` (`src/cli/exec/call.ts`) from the run's **live** captured output — failed-step detail, raw stderr/stdout, and collected `log` messages — whereas only the durable `run_summary.jsonl` copies were credential-redacted by `RuntimeEventEmitter`; a workflow that echoed a `*_API_KEY` / `*_TOKEN` / `*_SECRET` value to a stream and then failed leaked it verbatim through `?wait=true`, `GET /v1/runs/{id}`, and MCP tool results. The redaction rule is now **one shared helper**, `redactCredentials` (extracted from `RuntimeEventEmitter` into the new `src/runtime/kernel/redact.ts` — same credential-key suffixes, same ≥8-char threshold), imported by both the journal writer and `composeResult`. `composeResult` redacts the fully assembled failure text once, so step detail, raw streams, and logs all pass the same boundary regardless of which branch contributed them, and it does not rely on the (unredacted-at-source) live `__JAIPH_EVENT__` stream. This is the single boundary both `jaiph serve` (`result_text`) and `jaiph mcp` (tool results) return through; on the Docker path the `--env` passthrough (which flows through `DockerSpawnOptions.extraEnv`, not `runtimeEnv`) is merged back in so those values redact too. **Successful return values are unchanged:** a workflow's `return` is intentional API output, not diagnostic capture, and is returned verbatim (its `log`-output fallback, being diagnostic capture, is redacted). Tests: `src/cli/exec/call.test.ts`, and integration coverage that a failing workflow echoing a credential to both streams never exposes the value through `?wait=true`, `GET /v1/runs/{id}` (`integration/serve-server.test.ts`), or an MCP tool result (`integration/mcp-server.test.ts`) while `[REDACTED]` and the failed-step diagnostics are retained; the existing event-journal, OTLP, and Sentry redaction tests still pass. Docs: [Serve workflows over HTTP](docs/serve.md), [Call a tool and read the result](docs/mcp.md), and the redaction section of [Architecture](docs/architecture.md).
+
- **Feat — standalone runtime image: run jaiph in Docker/Kubernetes without a host orchestrator:** The published `ghcr.io/jaiphlang/jaiph-runtime` image (`runtime/Dockerfile`) already contained `jaiph`, the claude/cursor/codex backends, and a full toolchain, but it was only ever used as a *sandbox rootfs orchestrated by a host jaiph process*; running it directly (`docker run … jaiph run flow.jh`, or as a k8s pod) failed because jaiph defaults to Docker sandboxing on Linux and there is no Docker daemon inside the container. The image now **bakes `ENV JAIPH_UNSAFE=true`** (with a rationale comment: inside the image the container *is* the sandbox, so host-mode execution is correct and the interactive `--unsafe` confirmation is impossible/meaningless in an unattended pod), so the "put credentials + jaiph files and run it" story works on every build. **Host-orchestrated sandbox behavior is unchanged:** no `ENTRYPOINT` is added and `WORKDIR` is untouched (the host passes an explicit command argv that a prefix would corrupt), and the container-inner invocation the host uses is `jaiph run --raw`, which by contract never launches Docker regardless of `JAIPH_UNSAFE` — the full Docker e2e suite stays green with the ENV present. New `src/runtime/docker.ts` seams back the standalone story: an injectable `_containerIndicator.present()` (true when `/.dockerenv` — Docker — or `/run/.containerenv` — Podman / CRI — exists) and `isRunningInContainer()`; `checkDockerAvailable()` now, when Docker is unavailable **and** a container indicator is present, throws `E_DOCKER_NOT_FOUND … jaiph is running inside a container already. Set JAIPH_UNSAFE=true to run in host mode (the container is the sandbox). See https://jaiph.org/deploy` (covering users of derived images without the baked ENV) — with no indicator the original "Install Docker …" error is unchanged. `confirmUnsafeRun` (`src/runtime/docker-inplace.ts`) now, inside a container, proceeds with a one-line stderr notice (`running host-only inside a container (the container is the sandbox)`) instead of prompting or aborting `E_UNSAFE_NO_CONFIRM` — mirroring the win32 host-only override — which is what lets an unattended pod run. No new `JAIPH_*` env vars. Tests: `src/runtime/docker.test.ts` (indicator present → the container guidance with `JAIPH_UNSAFE=true`; indicator absent → the install-Docker error verbatim), `src/runtime/docker-inplace.test.ts` (in-container non-TTY without auto-confirm proceeds without opening a prompt and prints the notice; on a host the same conditions still throw `E_UNSAFE_NO_CONFIRM`), and a gated `e2e/tests/148_standalone_image.sh` (wired into `e2e/test_all.sh`) that runs the built image standalone on a fixture `hello.jh` with **no** `-e JAIPH_UNSAFE` and no Docker daemon inside, asserting the `return` value round-trips to `return_value.txt` and exit `0` — proving host mode comes purely from the baked ENV. CI: a new `k8s-manifest` job in `.github/workflows/ci.yml` provisions a throwaway kind cluster and runs `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` on every build. Docs: new [Deploy the runtime image standalone](docs/deploy.md) how-to (one-shot `docker run` with the claude/cursor/codex credential variants, a CI note pointing at the pattern `nightly-engineer.yml` uses, Kubernetes, and a plainly-stated **no-jaiph-sandbox security posture** — isolation is the container/pod boundary and workspace content policy is the operator's responsibility), an apply-ready `docs/deploy/k8s.yaml` (Deployment + Secret for backend credentials and `JAIPH_SERVE_TOKEN` + Service, `jaiph serve --host 0.0.0.0` with `/healthz` liveness/readiness probes, tag-pinning / TLS-via-ingress / resource-request guidance), links from README, [Sandboxing](docs/sandboxing.md), and [Install & switch versions](docs/setup.md), an updated `JAIPH_UNSAFE` row in [Environment variables](docs/env-vars.md) noting the image bakes it, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23` — "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … it can't depend on a local machine + docker".)
- **Feat — OTLP trace export: one span tree per run, zero dependencies:** A completed run now exports as an OpenTelemetry trace to any OTLP/HTTP collector (a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog), so operators running workflows in CI or as a service get a per-run latency breakdown and failure signal in a standard observability stack instead of only the local run dir. Export is **host-side and end-of-run**, in the new `src/cli/telemetry/otlp.ts` (zero runtime dependencies — a `node:https`/`node:http` request is the whole transport): after a run reaches terminal state the CLI reads *that run's* `run_summary.jsonl` and posts one trace. Nothing new crosses the sandbox boundary and no `OTEL_*` variable is forwarded into the container — the journal is complete (it carries the `WORKFLOW_*`/`PROMPT_*` records the live stderr stream omits), already credential-redacted by `RuntimeEventEmitter`, and host-visible in every sandbox mode (the run dir is a host mount); runs are minutes long, so end-of-run batching is the normal OTLP pattern anyway. The pure `runSummaryToOtlp(lines, meta)` maps journal lines + `{workflow, exitStatus, signal, serviceName, resourceAttributes}` to an OTLP/HTTP **JSON** `ExportTraceServiceRequest`: **trace id** = the run-id UUID with dashes stripped (32 hex, per the spec's JSON mapping), **span id** = first 16 hex of `sha256()` — deterministic, so re-exporting a run yields byte-identical ids. One **root span** `workflow ` per run (`WORKFLOW_START`→`WORKFLOW_END` timestamps, falling back to first/last event `ts`; status `ERROR` when the run exits nonzero or a signal killed it, else `OK`; each `LOGERR`/`LOGWARN` becomes a span event on it); one **step span** per `STEP_START`/`STEP_END` pair matched by event `id`, parented via `parent_id` (root when null), `kind: SPAN_KIND_INTERNAL`, attributes `jaiph.step.{kind,func,name,seq,depth,status,elapsed_ms}` plus the redacted `jaiph.step.out`/`jaiph.step.err` captures, span status `ERROR` on a nonzero step status; **prompt spans** as children of their `step_id` with `jaiph.prompt.{backend,model,status}`. A `STEP_START` with no matching `STEP_END` (a crash) closes at the last event's `ts` with status `ERROR`; ISO `ts` → `timeUnixNano` strings. Resource attributes: `service.name` from `OTEL_SERVICE_NAME` (default `jaiph`), the `OTEL_RESOURCE_ATTRIBUTES` pairs, plus `jaiph.version`, `jaiph.run_id`, `jaiph.workflow`, `jaiph.source`. **Enablement is standard OTEL env — no new `JAIPH_*`:** export runs iff `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (used verbatim) or `OTEL_EXPORTER_OTLP_ENDPOINT` (base URL, `/v1/traces` appended) is set (traces-specific wins when both are); `OTEL_EXPORTER_OTLP_HEADERS` (comma-separated `k=v`) is applied; only `http/json` is spoken — any other `OTEL_EXPORTER_OTLP_PROTOCOL` (e.g. `grpc`) warns on stderr and skips rather than mis-speak a protocol. A single shared hook `exportRunTelemetry({runDir, workflow, exitStatus, signal, env})` fires at every host terminal state — `jaiph run` completion (`reportResult` in `src/cli/commands/run.ts`) and the shared workflow-call layer (`src/cli/exec/call.ts`, covering every MCP `tools/call` and `jaiph serve` invocation) — one choke point covering host, Docker snapshot, and inplace modes. **Telemetry is never load-bearing:** an unreachable or erroring collector (or the 10 s timeout) produces exactly one stderr warning line and leaves the run's exit code, output, and journal untouched — no retries, no queue. Tests: `src/cli/telemetry/otlp.test.ts` (fixture journals — trace id from run id, step parenting per `parent_id`, prompt child of `step_id` with backend/model, failed step → span status 2, nonzero exit → root status 2, ISO→nano strings, unmatched `STEP_START` closes ERROR at the last event time, deterministic ids across two invocations; endpoint-resolution precedence, header parsing, `http/json`-only guard) and `integration/otlp-export.test.ts` (a fake collector receives exactly one well-formed POST to `/v1/traces` after a `jaiph run` with the env set, and nothing with no OTEL env; a `500` and a connection-refused collector each leave exit `0` with exactly one stderr warning; a shared-call/MCP invocation triggers exactly one export per call; a credential echoed in step output appears in the payload only as `[REDACTED]`). `package.json` `dependencies` stays absent. Docs: new [Export traces to an OTLP collector](observability.md) how-to, a "Telemetry variables" section in [Environment variables](env-vars.md), a README feature bullet, and the features overview on `docs/index.html`. (Observability feedback `2026-07-23` — "OTEL + Sentry configurable would be the easiest way".)
diff --git a/QUEUE.md b/QUEUE.md
index cb144034..466be5c4 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,22 +14,6 @@ Process rules:
***
-## Redact credentials from every workflow result surface #dev-ready
-
-`src/cli/exec/call.ts` builds failed-call text from live stderr events, while the durable journal is credential-redacted. As a result, `jaiph serve` can return a secret in `result_text` and `jaiph mcp` can return it in a tool error even though events, OTLP, and Sentry redact the same value.
-
-Scope:
-
-- Build failed-step details and other returned output from redacted data, using one redaction boundary shared by HTTP and MCP.
-- Cover `failedStep.detail`, raw stderr/stdout, and collected log messages; do not rely only on the durable event endpoint.
-- Keep successful return values unchanged: workflow return values are intentional API output, not diagnostic capture.
-
-Acceptance:
-
-- An integration workflow that prints a credential-like env value and fails never exposes the value through `?wait=true`, `GET /v1/runs/{id}`, or an MCP tool result.
-- The same test proves `[REDACTED]` is returned where diagnostic context is retained.
-- Existing event-journal, OTLP, and Sentry redaction tests still pass.
-
## Keep MCP generations alive while calls are in flight #dev-ready
`jaiph mcp` handles tool calls concurrently, but hot reload immediately deletes the previous generation's scripts directory. A call started just before reload can still need those scripts. Signal shutdown also removes the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounts generations; MCP needs the same lifecycle guarantee.
diff --git a/docs/architecture.md b/docs/architecture.md
index b5c1dcf2..be4444b7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -181,7 +181,9 @@ Before `RuntimeEventEmitter` writes an event line to `run_summary.jsonl`, it red
This covers backend API keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY` (the same names on the [Docker env allowlist](sandboxing.md)).
-Redaction applies **only to the copies embedded in `run_summary.jsonl`**. The per-step raw capture files (`%06d-.out` / `.err`) are streamed to disk verbatim and are not redacted — treat them, and the run directory as a whole, as sensitive.
+The same credential rule lives in one shared helper — **`redactCredentials`** (`src/runtime/kernel/redact.ts`) — which is also the redaction boundary for returned call results: `composeResult` (`src/cli/exec/call.ts`) redacts a failed call's diagnostic capture (failed-step detail, raw stderr/stdout, collected `log` messages) before it becomes `jaiph serve`'s `result_text` or a `jaiph mcp` tool result. A successful workflow's **return value** is intentional API output, not diagnostic capture, and is returned verbatim.
+
+Beyond those two boundaries (journal copies and returned call results), redaction is not applied. The per-step raw capture files (`%06d-.out` / `.err`) are streamed to disk verbatim and are not redacted — treat them, and the run directory as a whole, as sensitive.
## Channels and hooks in context
diff --git a/docs/mcp.md b/docs/mcp.md
index a5470f31..bdd9c959 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -100,7 +100,7 @@ A workflow with no parameters produces the same shape with an empty `properties`
On `tools/call`, the server maps the arguments object to positional workflow arguments in declared order and runs the workflow — in a Docker sandbox or on the host, per the same env-driven selection as `jaiph run` (see [Safety posture](#safety-posture)). The result is a text content block:
- **On success**, the text is the workflow's `return` value (persisted as `return_value.txt`); if the workflow returns nothing, it falls back to the workflow's `log` output, then to a `workflow completed` note.
-- **On failure**, the result carries `isError: true` and text describing the failing step, its captured output, and a `run dir: ` pointer so the client can inspect the full run.
+- **On failure**, the result carries `isError: true` and text describing the failing step, its captured output, and a `run dir: ` pointer so the client can inspect the full run. This failure text is **credential-redacted** (`[REDACTED]`) the same way as the event journal, so a secret echoed by a failing step is never returned to the client. The successful `return` value above is intentional API output and is returned verbatim.
A **workflow failure is not a protocol error** — it comes back as a normal result with `isError: true`. Protocol-level errors (JSON-RPC `-32602`) are reserved for calls that never start: an unknown tool name, a missing or non-string required argument, or an unexpected argument key.
diff --git a/docs/serve.md b/docs/serve.md
index 23bef68b..c820d692 100644
--- a/docs/serve.md
+++ b/docs/serve.md
@@ -48,7 +48,7 @@ curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
-H 'content-type: application/json' -d '{"name":"world"}' | jq
```
-The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first), and stop one with `POST /v1/runs/{id}/cancel`.
+The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. Failure narratives are credential-redacted (`[REDACTED]`) the same way as the event journal; the `return` value of a successful run is intentional API output and returned verbatim. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first), and stop one with `POST /v1/runs/{id}/cancel`.
## 4. Watch a run as it executes
diff --git a/integration/mcp-server.test.ts b/integration/mcp-server.test.ts
index 5cb9e561..0979e2ef 100644
--- a/integration/mcp-server.test.ts
+++ b/integration/mcp-server.test.ts
@@ -315,6 +315,38 @@ test("jaiph mcp --env GREETING=hi: every tools/call sees the var in the result t
}
});
+const LEAK_FAIL_FIXTURE = [
+ 'script leak_fail = `echo "stdout token $LEAK_API_KEY"; echo "stderr token $LEAK_API_KEY" >&2; exit 1`',
+ "# Echoes a credential to both streams then fails, to exercise tool-result redaction.",
+ "workflow leak_and_fail() {",
+ " run leak_fail()",
+ "}",
+ "",
+].join("\n");
+
+test("jaiph mcp: a failing tool call that prints a credential returns [REDACTED], never the value", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-redact-fail-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, LEAK_FAIL_FIXTURE);
+ const secret = "supersecretvalue123";
+ const client = startMcp(jh, root, mcpEnv(join(root, ".jaiph/runs")), false, ["--env", `LEAK_API_KEY=${secret}`]);
+ try {
+ await initialize(client);
+ client.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "leak_and_fail", arguments: {} } });
+ const res = await client.waitFor((m) => m.id === 1, "leak_and_fail call response");
+ assert.equal(res.error, undefined, "workflow failure must not be a protocol error");
+ const result = res.result as { content: Array<{ type: string; text: string }>; isError: boolean };
+ assert.equal(result.isError, true);
+ const text = result.content[0].text;
+ assert.ok(!text.includes(secret), "the MCP tool result must not contain the credential");
+ assert.ok(text.includes("[REDACTED]"), "diagnostic context is retained with the marker");
+ assert.match(text, /failed step/, "failed-step diagnostics survive redaction");
+ } finally {
+ await client.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("jaiph mcp --env GREETING (bare) forwards the host value to every call", async () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-mcp-env-bare-"));
const jh = join(root, "tools.jh");
diff --git a/integration/serve-server.test.ts b/integration/serve-server.test.ts
index 28b9b5b1..f645b456 100644
--- a/integration/serve-server.test.ts
+++ b/integration/serve-server.test.ts
@@ -41,6 +41,12 @@ const BASE_FIXTURE = [
' return "done"',
"}",
"",
+ 'script leak_fail = `echo "stdout token $LEAK_API_KEY"; echo "stderr token $LEAK_API_KEY" >&2; exit 1`',
+ "# Echoes a credential to both streams then fails, to exercise result_text redaction.",
+ "workflow leak_and_fail() {",
+ " run leak_fail()",
+ "}",
+ "",
'script publish = `printf \'artifact-payload\' > "$JAIPH_ARTIFACTS_DIR/result.txt"`',
"# Publishes a file into the run's artifacts dir.",
"workflow make_artifact() {",
@@ -393,6 +399,35 @@ test("jaiph serve: a credential echoed by a run is [REDACTED] in the event strea
}
});
+test("jaiph serve: a failing run's result_text is [REDACTED] via wait=true and GET /v1/runs/{id}", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-redact-fail-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const secret = "supersecretvalue123";
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")), ["--env", `LEAK_API_KEY=${secret}`]);
+ try {
+ const res = await fetch(`${srv.baseUrl}/v1/workflows/leak_and_fail/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(res.status, 200);
+ const run = await res.json();
+ assert.equal(run.status, "failed");
+ assert.ok(!run.result_text.includes(secret), "wait=true result_text must not contain the credential");
+ assert.ok(run.result_text.includes("[REDACTED]"), "diagnostic context is retained with the marker");
+ assert.match(run.result_text, /failed step/, "failed-step diagnostics survive redaction");
+
+ const again = await (await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}`)).json();
+ assert.equal(again.status, "failed");
+ assert.ok(!again.result_text.includes(secret), "GET /v1/runs/{id} result_text must not contain the credential");
+ assert.ok(again.result_text.includes("[REDACTED]"), "the marker persists on the durable run object");
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("jaiph serve: artifacts round-trip — list then byte-identical download, traversal is 404", async () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-serve-art-"));
const jh = join(root, "tools.jh");
diff --git a/src/cli/exec/call.test.ts b/src/cli/exec/call.test.ts
new file mode 100644
index 00000000..410101e3
--- /dev/null
+++ b/src/cli/exec/call.test.ts
@@ -0,0 +1,75 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { composeResult, type CollectedOutput } from "./call";
+
+const SECRET = "supersecretvalue123";
+const ENV: NodeJS.ProcessEnv = { LEAK_API_KEY: SECRET };
+
+const OK = { status: 0, signal: null } as const;
+const FAIL = { status: 1, signal: null } as const;
+
+function collected(partial: Partial): CollectedOutput {
+ return { logs: [], rawStderr: "", rawStdout: "", ...partial };
+}
+
+test("composeResult redacts the credential from every failure part but keeps context", () => {
+ const result = composeResult(
+ "boom",
+ collected({
+ failedStep: { name: "script leak", detail: `token is ${SECRET}` },
+ rawStderr: `stderr saw ${SECRET}`,
+ rawStdout: `stdout saw ${SECRET}`,
+ logs: [`log saw ${SECRET}`],
+ }),
+ FAIL,
+ "/runs/x",
+ undefined,
+ ENV,
+ );
+ assert.equal(result.isError, true);
+ assert.ok(!result.text.includes(SECRET), "failure text must not contain the credential");
+ assert.ok(result.text.includes("failed step: script leak"), "step context is retained");
+ assert.ok(result.text.includes("token is [REDACTED]"), "step detail keeps context around the marker");
+ assert.ok(result.text.includes("stderr saw [REDACTED]"), "raw stderr is redacted");
+ assert.ok(result.text.includes("log output:\nlog saw [REDACTED]"), "collected logs are redacted");
+ assert.ok(result.text.includes("run dir: /runs/x"), "run dir pointer is retained");
+});
+
+test("composeResult redacts raw stdout on failure when it is the only detail", () => {
+ const result = composeResult("boom", collected({ rawStdout: `only stdout ${SECRET}` }), FAIL, undefined, undefined, ENV);
+ assert.ok(!result.text.includes(SECRET));
+ assert.equal(result.text.includes("only stdout [REDACTED]"), true);
+});
+
+test("composeResult leaves non-credential env values and short credentials alone", () => {
+ const result = composeResult(
+ "boom",
+ collected({ rawStderr: "greeting hello-world, short abc" }),
+ FAIL,
+ undefined,
+ undefined,
+ { GREETING: "hello-world", TINY_API_KEY: "abc" },
+ );
+ assert.ok(result.text.includes("greeting hello-world, short abc"), "non-credential text is untouched");
+});
+
+test("composeResult returns a successful return value verbatim (intentional API output)", () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-call-rv-"));
+ try {
+ writeFileSync(join(runDir, "return_value.txt"), `deploy key ${SECRET}\n`);
+ const result = composeResult("ok", collected({}), OK, runDir, undefined, ENV);
+ assert.equal(result.isError, false);
+ assert.equal(result.text, `deploy key ${SECRET}`, "return values are not diagnostic capture");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("composeResult redacts the log-fallback success text (diagnostic capture)", () => {
+ const result = composeResult("ok", collected({ logs: [`log saw ${SECRET}`] }), OK, undefined, undefined, ENV);
+ assert.equal(result.isError, false);
+ assert.equal(result.text, "log saw [REDACTED]");
+});
diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts
index e81775c2..62ace220 100644
--- a/src/cli/exec/call.ts
+++ b/src/cli/exec/call.ts
@@ -17,6 +17,7 @@ import {
} from "../../runtime/docker";
import { discoverDockerRunDir, remapContainerPath } from "../shared/errors";
import { exportRunTelemetry } from "../telemetry/otlp";
+import { redactCredentials } from "../../runtime/kernel/redact";
/**
* Result of executing one workflow call. `text` is the same content an MCP
@@ -78,7 +79,7 @@ export interface WorkflowCallEnvironment {
}
/** Output accumulated from a run child's streams while it executes. */
-interface CollectedOutput {
+export interface CollectedOutput {
logs: string[];
failedStep?: { name: string; detail: string };
rawStderr: string;
@@ -152,7 +153,7 @@ async function callWorkflowHost(
collector.drain();
const meta = readMetaFile(metaFile);
- return composeResult(workflowSymbol, collector.data, exit, meta.runDir, undefined);
+ return composeResult(workflowSymbol, collector.data, exit, meta.runDir, undefined, runtimeEnv);
}
/**
@@ -197,7 +198,12 @@ async function callWorkflowDocker(
const exit = await waitForRunExit(dockerResult.child);
collector.drain();
const discovered = discoverDockerRunDir(sandboxRunDir, runId);
- return composeResult(workflowSymbol, collector.data, exit, discovered.runDir, sandboxRunDir);
+ // Docker keeps `--env` passthrough out of runtimeEnv (it flows through
+ // DockerSpawnOptions.extraEnv), so merge it back in for redaction.
+ return composeResult(workflowSymbol, collector.data, exit, discovered.runDir, sandboxRunDir, {
+ ...runtimeEnv,
+ ...env.extraEnv,
+ });
});
}
@@ -260,13 +266,24 @@ function attachOutputCollector(
};
}
-/** Compose the call result text from a finished run's output + run dir. */
-function composeResult(
+/**
+ * Compose the call result text from a finished run's output + run dir.
+ *
+ * Diagnostic capture — failed-step detail, raw stderr/stdout, and collected
+ * `log` messages — is credential-redacted here, the single boundary both
+ * `jaiph serve` (`result_text`, `?wait=true`, `GET /v1/runs/{id}`) and
+ * `jaiph mcp` (tool results) return through. Live `__JAIPH_EVENT__` lines are
+ * not redacted at the source, so this must not rely on the event stream.
+ * A successful workflow's return value is intentional API output, not
+ * diagnostic capture, and is returned verbatim.
+ */
+export function composeResult(
workflowSymbol: string,
data: CollectedOutput,
exit: { status: number; signal: NodeJS.Signals | null },
runDir: string | undefined,
sandboxRunDir: string | undefined,
+ env: NodeJS.ProcessEnv,
): WorkflowCallResult {
const failed = exit.status !== 0 || exit.signal !== null;
@@ -276,7 +293,7 @@ function composeResult(
returnValue !== undefined && returnValue.length > 0
? returnValue
: data.logs.length > 0
- ? data.logs.join("\n")
+ ? redactCredentials(data.logs.join("\n"), env)
: `workflow ${workflowSymbol} completed`;
return { text: trimTrailingNewline(text), isError: false, runDir, exitStatus: exit.status, signal: exit.signal };
}
@@ -297,7 +314,16 @@ function composeResult(
if (!data.failedStep && !stderrTrimmed && stdoutTrimmed) parts.push(stdoutTrimmed);
if (data.logs.length > 0) parts.push(`log output:\n${data.logs.join("\n")}`);
if (runDir) parts.push(`run dir: ${runDir}`);
- return { text: parts.join("\n\n"), isError: true, runDir, exitStatus: exit.status, signal: exit.signal };
+ // Redact the assembled failure text once so every part — step detail, raw
+ // streams, and logs — passes the same boundary regardless of which branch
+ // contributed it.
+ return {
+ text: redactCredentials(parts.join("\n\n"), env),
+ isError: true,
+ runDir,
+ exitStatus: exit.status,
+ signal: exit.signal,
+ };
}
function readMetaFile(metaFile: string): { runDir?: string; summaryFile?: string } {
diff --git a/src/runtime/kernel/redact.ts b/src/runtime/kernel/redact.ts
new file mode 100644
index 00000000..7253b28c
--- /dev/null
+++ b/src/runtime/kernel/redact.ts
@@ -0,0 +1,24 @@
+/**
+ * Credential redaction shared by every surface that persists or returns
+ * workflow output: the durable `run_summary.jsonl` writes in
+ * `RuntimeEventEmitter`, and the call-result text composed for `jaiph serve`
+ * and `jaiph mcp` (`src/cli/exec/call.ts`). One definition of "credential"
+ * keeps the journal, HTTP, and MCP surfaces in agreement.
+ */
+
+const CREDENTIAL_KEY_SUFFIXES = ["_API_KEY", "_TOKEN", "_SECRET", "_API_TOKEN"] as const;
+
+export function isCredentialKey(key: string): boolean {
+ const upper = key.toUpperCase();
+ return CREDENTIAL_KEY_SUFFIXES.some((s) => upper.endsWith(s));
+}
+
+/** Replace each credential env value (≥8 chars) found in `text` with [REDACTED]. */
+export function redactCredentials(text: string, env: NodeJS.ProcessEnv): string {
+ let result = text;
+ for (const [key, value] of Object.entries(env)) {
+ if (!value || value.length < 8 || !isCredentialKey(key)) continue;
+ result = result.split(value).join("[REDACTED]");
+ }
+ return result;
+}
diff --git a/src/runtime/kernel/runtime-event-emitter.ts b/src/runtime/kernel/runtime-event-emitter.ts
index 71103595..4fafbf6c 100644
--- a/src/runtime/kernel/runtime-event-emitter.ts
+++ b/src/runtime/kernel/runtime-event-emitter.ts
@@ -8,25 +8,9 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { appendRunSummaryLine, CHAIN_GENESIS, sha256hex } from "./emit";
+import { redactCredentials } from "./redact";
import { MAX_EMBED, nowIso, sanitizeName, stripOuterQuotes } from "./runtime-arg-parser";
-const CREDENTIAL_KEY_SUFFIXES = ["_API_KEY", "_TOKEN", "_SECRET", "_API_TOKEN"] as const;
-
-function isCredentialKey(key: string): boolean {
- const upper = key.toUpperCase();
- return CREDENTIAL_KEY_SUFFIXES.some((s) => upper.endsWith(s));
-}
-
-/** Replace each credential env value (≥8 chars) found in `text` with [REDACTED]. */
-function redactCredentials(text: string, env: NodeJS.ProcessEnv): string {
- let result = text;
- for (const [key, value] of Object.entries(env)) {
- if (!value || value.length < 8 || !isCredentialKey(key)) continue;
- result = result.split(value).join("[REDACTED]");
- }
- return result;
-}
-
export type Frame = {
id: string;
kind: string;
From eb1620a1a5c3d78ce11450f4e8827a5943ef2ab8 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 13:31:15 +0200
Subject: [PATCH 08/24] Fix: keep MCP generations alive while calls are in
flight
jaiph mcp handled tool calls concurrently, but a hot reload deleted the
previous generation's out dir (emitted scripts + serialized graph) the
instant it swapped, so a call started just before the reload lost the
scripts its remaining steps spawn from. Signal shutdown also removed the
shared temp root without draining or cancelling active calls.
Extract the LiveGeneration refcount model that jaiph serve already used
into createGenerationTracker in src/cli/shared/generation.ts and share it
between both commands. Each call acquires a lease on the generation live
at call start; a superseded generation's out dir is deleted only when its
last lease releases, so a call spanning one or more reloads always runs
from the generation it captured and no directory is left behind.
Shutdown is now drain-then-cancel: stdin close or the first SIGINT/SIGTERM
stops accepting input and awaits in-flight calls before cleanup, exiting
0. A second signal invokes McpServer.cancelAll(), terminating each run's
child process tree (SIGINT then SIGKILL) and force-removing its Docker
container, so no child process or container is orphaned; killed runs
settle with normal isError responses so the drain still completes.
Adds generation.test.ts, server.test.ts cancelAll coverage, and e2e test
149_mcp_generation_lifecycle.sh; updates CLI and MCP docs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
QUEUE.md | 16 --
docs/cli.md | 3 +-
docs/mcp.md | 5 +
e2e/test_all.sh | 1 +
e2e/tests/149_mcp_generation_lifecycle.sh | 239 ++++++++++++++++++++++
src/cli/commands/mcp.ts | 79 ++++---
src/cli/commands/serve.ts | 53 ++---
src/cli/mcp/server.test.ts | 35 ++++
src/cli/mcp/server.ts | 11 +
src/cli/shared/generation.test.ts | 108 ++++++++++
src/cli/shared/generation.ts | 66 +++++-
12 files changed, 538 insertions(+), 80 deletions(-)
create mode 100755 e2e/tests/149_mcp_generation_lifecycle.sh
create mode 100644 src/cli/shared/generation.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f7a3481..f2fd07b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@
## All changes
+- **Fix — keep `jaiph mcp` generations alive while calls are in flight, and drain before shutdown:** `jaiph mcp` handles tool calls concurrently, but a hot reload used to delete the previous generation's out dir (emitted scripts + serialized graph) the instant it swapped — `rmSync(previousOutDir, …)` ran synchronously in the reload handler — so a call that started just before the reload lost the scripts its remaining steps spawn from, and signal shutdown removed the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounted its generations (HTTP runs can outlive a reload); MCP now gets the same guarantee from a **shared** lifecycle. The `LiveGeneration` refcount model was extracted from `src/cli/commands/serve.ts` into `createGenerationTracker` in `src/cli/shared/generation.ts` and is now used by both commands: each call `acquire()`s a **lease** on the generation live at call start (`lease.state` is stable for the call's lifetime), and a superseded generation's out dir is deleted only when its **last lease releases** — immediately on swap when idle, otherwise when the final in-flight call settles. A call spanning one or more reloads therefore always runs its remaining script steps from the generation it captured, and no generation directory is left behind after all calls settle. **Shutdown is now drain-then-cancel** (matching `jaiph serve`): stdin closing or the **first** `SIGINT`/`SIGTERM` stops accepting input and awaits every in-flight call before removing the temp root and exiting `0` — a draining call keeps its scripts on disk until it settles. A **second** signal cancels instead of waiting: the new `McpServer.cancelAll()` (`src/cli/mcp/server.ts`) invokes each in-flight call's cancel handle, terminating its run's child process tree (`SIGINT`, then `SIGKILL` after a grace period) and, in Docker mode, force-removing its container (`docker rm -f`), so no child process or container is orphaned; unlike a client `notifications/cancelled`, the calls are **not** marked cancelled — each killed run settles with a normal `isError` response, so the drain awaiting them completes deterministically and the server still exits `0`. Tests: new `src/cli/shared/generation.test.ts` (lease pins the generation across a swap; last release deletes the superseded dir; concurrent leases across multiple swaps settle out-of-order and leave only the current dir; release is idempotent and cannot free a dir another lease still holds), `src/cli/mcp/server.test.ts` (`cancelAll` kills every in-flight run yet still delivers each `isError` response), and a new `e2e/tests/149_mcp_generation_lifecycle.sh` (wired into `e2e/test_all.sh`): a slow call started before a reload returns the **old** generation's value while a post-reload call returns the **new** one and stdin-close drains both; SIGTERM drains an in-flight call to completion with no orphaned script process; a second SIGTERM cancels with an `isError` result and no orphan. Docs: updated `jaiph mcp` shutdown/exit behavior and a generation-lease note in [CLI](docs/cli.md#jaiph-mcp), an in-flight-call note under hot reload and a new "Shutdown (drain, then cancel)" section in [Serve workflows as MCP tools](docs/mcp.md).
+
- **Fix — redact credentials from every returned workflow result, not just the durable journal:** `jaiph serve`'s `result_text` and a `jaiph mcp` tool error could return a secret that events, OTLP, and Sentry all redacted. The failure text was built in `composeResult` (`src/cli/exec/call.ts`) from the run's **live** captured output — failed-step detail, raw stderr/stdout, and collected `log` messages — whereas only the durable `run_summary.jsonl` copies were credential-redacted by `RuntimeEventEmitter`; a workflow that echoed a `*_API_KEY` / `*_TOKEN` / `*_SECRET` value to a stream and then failed leaked it verbatim through `?wait=true`, `GET /v1/runs/{id}`, and MCP tool results. The redaction rule is now **one shared helper**, `redactCredentials` (extracted from `RuntimeEventEmitter` into the new `src/runtime/kernel/redact.ts` — same credential-key suffixes, same ≥8-char threshold), imported by both the journal writer and `composeResult`. `composeResult` redacts the fully assembled failure text once, so step detail, raw streams, and logs all pass the same boundary regardless of which branch contributed them, and it does not rely on the (unredacted-at-source) live `__JAIPH_EVENT__` stream. This is the single boundary both `jaiph serve` (`result_text`) and `jaiph mcp` (tool results) return through; on the Docker path the `--env` passthrough (which flows through `DockerSpawnOptions.extraEnv`, not `runtimeEnv`) is merged back in so those values redact too. **Successful return values are unchanged:** a workflow's `return` is intentional API output, not diagnostic capture, and is returned verbatim (its `log`-output fallback, being diagnostic capture, is redacted). Tests: `src/cli/exec/call.test.ts`, and integration coverage that a failing workflow echoing a credential to both streams never exposes the value through `?wait=true`, `GET /v1/runs/{id}` (`integration/serve-server.test.ts`), or an MCP tool result (`integration/mcp-server.test.ts`) while `[REDACTED]` and the failed-step diagnostics are retained; the existing event-journal, OTLP, and Sentry redaction tests still pass. Docs: [Serve workflows over HTTP](docs/serve.md), [Call a tool and read the result](docs/mcp.md), and the redaction section of [Architecture](docs/architecture.md).
- **Feat — standalone runtime image: run jaiph in Docker/Kubernetes without a host orchestrator:** The published `ghcr.io/jaiphlang/jaiph-runtime` image (`runtime/Dockerfile`) already contained `jaiph`, the claude/cursor/codex backends, and a full toolchain, but it was only ever used as a *sandbox rootfs orchestrated by a host jaiph process*; running it directly (`docker run … jaiph run flow.jh`, or as a k8s pod) failed because jaiph defaults to Docker sandboxing on Linux and there is no Docker daemon inside the container. The image now **bakes `ENV JAIPH_UNSAFE=true`** (with a rationale comment: inside the image the container *is* the sandbox, so host-mode execution is correct and the interactive `--unsafe` confirmation is impossible/meaningless in an unattended pod), so the "put credentials + jaiph files and run it" story works on every build. **Host-orchestrated sandbox behavior is unchanged:** no `ENTRYPOINT` is added and `WORKDIR` is untouched (the host passes an explicit command argv that a prefix would corrupt), and the container-inner invocation the host uses is `jaiph run --raw`, which by contract never launches Docker regardless of `JAIPH_UNSAFE` — the full Docker e2e suite stays green with the ENV present. New `src/runtime/docker.ts` seams back the standalone story: an injectable `_containerIndicator.present()` (true when `/.dockerenv` — Docker — or `/run/.containerenv` — Podman / CRI — exists) and `isRunningInContainer()`; `checkDockerAvailable()` now, when Docker is unavailable **and** a container indicator is present, throws `E_DOCKER_NOT_FOUND … jaiph is running inside a container already. Set JAIPH_UNSAFE=true to run in host mode (the container is the sandbox). See https://jaiph.org/deploy` (covering users of derived images without the baked ENV) — with no indicator the original "Install Docker …" error is unchanged. `confirmUnsafeRun` (`src/runtime/docker-inplace.ts`) now, inside a container, proceeds with a one-line stderr notice (`running host-only inside a container (the container is the sandbox)`) instead of prompting or aborting `E_UNSAFE_NO_CONFIRM` — mirroring the win32 host-only override — which is what lets an unattended pod run. No new `JAIPH_*` env vars. Tests: `src/runtime/docker.test.ts` (indicator present → the container guidance with `JAIPH_UNSAFE=true`; indicator absent → the install-Docker error verbatim), `src/runtime/docker-inplace.test.ts` (in-container non-TTY without auto-confirm proceeds without opening a prompt and prints the notice; on a host the same conditions still throw `E_UNSAFE_NO_CONFIRM`), and a gated `e2e/tests/148_standalone_image.sh` (wired into `e2e/test_all.sh`) that runs the built image standalone on a fixture `hello.jh` with **no** `-e JAIPH_UNSAFE` and no Docker daemon inside, asserting the `return` value round-trips to `return_value.txt` and exit `0` — proving host mode comes purely from the baked ENV. CI: a new `k8s-manifest` job in `.github/workflows/ci.yml` provisions a throwaway kind cluster and runs `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` on every build. Docs: new [Deploy the runtime image standalone](docs/deploy.md) how-to (one-shot `docker run` with the claude/cursor/codex credential variants, a CI note pointing at the pattern `nightly-engineer.yml` uses, Kubernetes, and a plainly-stated **no-jaiph-sandbox security posture** — isolation is the container/pod boundary and workspace content policy is the operator's responsibility), an apply-ready `docs/deploy/k8s.yaml` (Deployment + Secret for backend credentials and `JAIPH_SERVE_TOKEN` + Service, `jaiph serve --host 0.0.0.0` with `/healthz` liveness/readiness probes, tag-pinning / TLS-via-ingress / resource-request guidance), links from README, [Sandboxing](docs/sandboxing.md), and [Install & switch versions](docs/setup.md), an updated `JAIPH_UNSAFE` row in [Environment variables](docs/env-vars.md) noting the image bakes it, a README feature bullet, and the features overview on `docs/index.html`. (Deployability feedback `2026-07-23` — "a docker image with codex/cursor/claude code already installed so that the user only needs to put the credentials + the jaiph files and run it … it can't depend on a local machine + docker".)
diff --git a/QUEUE.md b/QUEUE.md
index 466be5c4..d5d84b46 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,22 +14,6 @@ Process rules:
***
-## Keep MCP generations alive while calls are in flight #dev-ready
-
-`jaiph mcp` handles tool calls concurrently, but hot reload immediately deletes the previous generation's scripts directory. A call started just before reload can still need those scripts. Signal shutdown also removes the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounts generations; MCP needs the same lifecycle guarantee.
-
-Scope:
-
-- Share or mirror the generation lease/refcount model for MCP and HTTP.
-- Delete a superseded generation only after its last call settles.
-- On stdin close, drain active calls before cleanup. On SIGINT/SIGTERM, use a documented drain-then-cancel policy and stop Docker containers as well as child processes.
-
-Acceptance:
-
-- A slow MCP call started before a source reload completes successfully after the new generation is active.
-- Concurrent calls spanning multiple reloads use the generation captured at call start and leave no generation directories after all calls settle.
-- Shutdown tests prove no active call reads deleted scripts and no child process/container is orphaned.
-
## Make the Kubernetes example runnable and hardened by default #dev-ready
The current manifest mounts the workflow ConfigMap read-only at `/work`, while standalone host mode writes runs under `/work/.jaiph/runs`; the example is schema-valid but cannot complete a real run. It also ships publicly known placeholder secrets and omits the pod hardening that `docs/deploy.md` says the operator must supply.
diff --git a/docs/cli.md b/docs/cli.md
index a73902ec..b13504d2 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -293,7 +293,7 @@ jaiph mcp [--workspace ] [--env KEY[=VALUE]]...
- Loads the module graph and runs `collectDiagnostics` (the same compile-time pass as `jaiph compile`). Any diagnostic prints `file:line:col CODE message` lines to **stderr** and exits `1`.
- A missing path, a non-`.jh` path, or a path that is not a file exits `1` with a message on stderr.
-- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`, then drains in-flight calls and exits `0`.
+- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`. Shutdown is **drain-then-cancel**: stdin closing (or the first signal) stops accepting input and waits for in-flight calls to finish before cleaning up and exiting `0` — a draining call keeps its scripts until it settles. A **second** signal cancels the in-flight calls instead of waiting: each run's child process tree is terminated (`SIGINT`, then `SIGKILL` after a grace period) and, in Docker mode, its container is force-removed (`docker rm -f`), so no child process or container outlives the server; the killed calls settle with error results and the server still exits `0`.
### stdout invariant
@@ -345,6 +345,7 @@ Tool descriptions come from the `#` comment lines directly above each workflow (
- Tool calls honor the same env-driven sandbox selection as `jaiph run` (`resolveDockerConfig`): Docker on macOS/Linux by default, host-only under `JAIPH_UNSAFE=true` or on Windows. The image is prepared once at startup (`checkDockerAvailable` + `prepareImage`), not per call. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`.
- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Set `JAIPH_INPLACE=1` to bind the real workspace read-write so tool effects land live (opt-in), or `JAIPH_UNSAFE=true` to run on the host with no sandbox.
- Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr.
+- Calls bind to the generation (emitted scripts + serialized graph) live when they start; a superseded generation's scripts dir survives until its last in-flight call settles, so a call spanning a reload still runs its remaining steps — the same lease model `jaiph serve` uses for HTTP runs.
## `jaiph serve`
{: #jaiph-serve}
diff --git a/docs/mcp.md b/docs/mcp.md
index bdd9c959..d57ee839 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -147,6 +147,11 @@ The server watches every source file in the module graph (polling, ~750 ms). Whe
- The graph is reloaded and re-validated, tools are re-derived, and the server emits `notifications/tools/list_changed`. A subsequent `tools/list` reflects the new tool set.
- If the edit introduces a **compile error**, the server keeps serving the previous, valid tool set and logs the diagnostics to stderr — clients are never left with a broken tool list.
+- A tool call already in flight is untouched: it keeps executing against the scripts of the generation it started under, and that generation's files are deleted only once its last in-flight call settles. New calls use the reloaded sources.
+
+## Shutdown (drain, then cancel)
+
+The server shuts down when stdin closes or on `SIGINT` / `SIGTERM`. Either way it first **drains**: it stops accepting input and waits for in-flight tool calls to finish — their scripts stay on disk until they settle — then cleans up and exits `0`. If you don't want to wait, send a **second** signal: every in-flight run's child process tree is terminated (`SIGINT`, then `SIGKILL` after a short grace period) and, in Docker mode, each call's container is force-removed (`docker rm -f`), the same no-orphan contract as per-call cancellation above. The killed calls report error results and the server exits `0`.
## Safety posture
diff --git a/e2e/test_all.sh b/e2e/test_all.sh
index f2ebda79..57cb38b7 100755
--- a/e2e/test_all.sh
+++ b/e2e/test_all.sh
@@ -104,6 +104,7 @@ TEST_SCRIPTS=(
"e2e/tests/140_env_passthrough.sh"
"e2e/tests/141_mcp_docker_sandbox.sh"
"e2e/tests/147_serve_http_api.sh"
+ "e2e/tests/149_mcp_generation_lifecycle.sh"
"e2e/tests/146_trusted_envs.sh"
"e2e/tests/148_standalone_image.sh"
"e2e/tests/210_standalone_binary.sh"
diff --git a/e2e/tests/149_mcp_generation_lifecycle.sh b/e2e/tests/149_mcp_generation_lifecycle.sh
new file mode 100755
index 00000000..fa4e5357
--- /dev/null
+++ b/e2e/tests/149_mcp_generation_lifecycle.sh
@@ -0,0 +1,239 @@
+#!/usr/bin/env bash
+#
+# MCP 7/8 — generation lifecycle: hot reload with in-flight calls + drain-then-cancel shutdown
+# ============================================================================================
+# Black-box coverage of the generation lease model (src/cli/shared/generation.ts)
+# through the real `jaiph` entrypoint:
+#
+# 1. A tool call started before a source reload keeps running against the
+# generation it captured at call start: its second script step (spawned
+# AFTER the reload swapped generations) still finds its scripts dir, and
+# the call returns the OLD generation's value. A call made after the
+# reload returns the NEW generation's value. Stdin closes while both
+# calls are still in flight, so this also proves stdin-close drains
+# active calls before cleanup. Gate files make the ordering
+# deterministic: the first call provably blocks across the reload.
+#
+# 2. SIGTERM with a call in flight drains: the server stops accepting input,
+# the call finishes normally AFTER the signal (its scripts must still be
+# on disk), its response is delivered, and the server exits 0.
+#
+# 3. A second SIGTERM cancels: the in-flight run's child process tree is
+# killed (no orphan — the script's recorded PID is gone after exit), the
+# call settles with an isError result, and the server still exits 0.
+#
+# The server's stdin is a pipe from a feeder subshell (not a FIFO — macOS does
+# not reliably deliver FIFO EOF to node), matching how real MCP clients spawn
+# stdio servers.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT_DIR}/e2e/lib/common.sh"
+trap e2e::cleanup EXIT
+
+e2e::prepare_test_env "mcp_generation_lifecycle"
+TEST_DIR="${JAIPH_E2E_TEST_DIR}"
+
+if ! command -v python3 >/dev/null 2>&1; then
+ e2e::fail "python3 required for JSON-RPC stdout validation"
+fi
+
+INIT_REQ='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}'
+
+# Wait until a pattern appears in a file (server stderr, pid files, ...).
+wait_for() {
+ local label="$1" path="$2" pattern="$3"
+ for _ in $(seq 1 100); do
+ if [[ -f "${path}" ]] && grep -q "${pattern}" "${path}" 2>/dev/null; then
+ return 0
+ fi
+ sleep 0.2
+ done
+ [[ -f "${path}" ]] && printf 'file was:\n%s\n' "$(cat "${path}")" >&2
+ e2e::fail "${label}"
+}
+
+# Extract `text` + `isError` of one tools/call response by id from a JSON-RPC
+# stdout capture. Prints "\n".
+response_fields() {
+ python3 - "$1" "$2" <<'PY'
+import json, sys
+
+wanted = int(sys.argv[2])
+for line in open(sys.argv[1], encoding="utf-8"):
+ if not line.strip():
+ continue
+ msg = json.loads(line)
+ if msg.get("id") == wanted and "result" in msg:
+ r = msg["result"]
+ print("true" if r.get("isError") else "false")
+ print(r["content"][0]["text"].splitlines()[0])
+ sys.exit(0)
+sys.exit(f"no result for id {wanted}")
+PY
+}
+
+# `slow` runs two script steps: `pause` blocks on a gate file (and records that
+# the call started via a pid file), then `stamp` — spawned only after the gate
+# appears — echoes this generation's marker.
+write_fixture() {
+ local file="$1" marker="$2" pidfile="$3" gate="$4"
+ cat > "${file}" < "${pidfile}"; while [ ! -f "${gate}" ]; do sleep 0.2; done\`
+script stamp = \`echo "${marker}"\`
+
+# Waits for the gate file, then reports its generation's marker.
+workflow slow() {
+ run pause()
+ const out = run stamp()
+ return out
+}
+EOF
+}
+
+# ---------------------------------------------------------------------------
+# 1. A slow call spans a hot reload and keeps its generation's scripts
+# ---------------------------------------------------------------------------
+e2e::section "in-flight call survives a hot reload on the generation it started under"
+
+gate1="${TEST_DIR}/gate1"
+pid1="${TEST_DIR}/pause1.pid"
+write_fixture "${TEST_DIR}/tools.jh" "generation-one" "${pid1}" "${gate1}"
+
+out1="${TEST_DIR}/mcp1.out"
+err1="${TEST_DIR}/mcp1.err"
+# The feeder sends the second call only after the reload is live, then exits —
+# closing stdin while BOTH calls are still gated, so the drain must keep the
+# generations' scripts alive until the gate opens and the calls settle.
+(
+ printf '%s\n' "${INIT_REQ}"
+ printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"slow","arguments":{}}}'
+ for _ in $(seq 1 150); do grep -q "sources reloaded" "${err1}" 2>/dev/null && break; sleep 0.2; done
+ printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"slow","arguments":{}}}'
+) | jaiph mcp "${TEST_DIR}/tools.jh" >"${out1}" 2>"${err1}" &
+E2E_SERVER_PID="$!"
+
+# The first call is provably in flight (its first script step wrote the pid
+# file) before the source is rewritten.
+wait_for "gen-1 call started" "${pid1}" "[0-9]"
+write_fixture "${TEST_DIR}/tools.jh" "generation-two" "${TEST_DIR}/pause2.pid" "${gate1}"
+wait_for "sources reloaded" "${err1}" "sources reloaded"
+
+# Only now — with the new generation active and stdin about to close — unblock
+# both calls; the old call's `stamp` step spawns from the superseded
+# generation's scripts dir.
+touch "${gate1}"
+
+mcp1_exit=0
+wait "${E2E_SERVER_PID}" || mcp1_exit=$?
+E2E_SERVER_PID=""
+e2e::assert_equals "${mcp1_exit}" "0" "server 1 exits 0 after stdin close drains both calls"
+
+fields="$(response_fields "${out1}" 3)" || { cat "${out1}" "${err1}" >&2; e2e::fail "no response for id 3"; }
+{ read -r p_iserror; read -r p_text; } <<< "${fields}"
+e2e::assert_equals "${p_iserror}" "false" "call started before the reload succeeds"
+e2e::assert_equals "${p_text}" "generation-one" "call started before the reload ran the OLD generation's scripts"
+
+fields="$(response_fields "${out1}" 4)" || { cat "${out1}" "${err1}" >&2; e2e::fail "no response for id 4"; }
+{ read -r p_iserror; read -r p_text; } <<< "${fields}"
+e2e::assert_equals "${p_iserror}" "false" "call made after the reload succeeds"
+e2e::assert_equals "${p_text}" "generation-two" "call made after the reload ran the NEW generation's scripts"
+
+# ---------------------------------------------------------------------------
+# 2. SIGTERM drains: the in-flight call finishes after the signal, exit 0
+# ---------------------------------------------------------------------------
+e2e::section "SIGTERM drains the in-flight call before cleanup"
+
+gate2="${TEST_DIR}/gate2"
+pid2="${TEST_DIR}/pause_drain.pid"
+done2="${TEST_DIR}/done2"
+write_fixture "${TEST_DIR}/tools_drain.jh" "drain-ok" "${pid2}" "${gate2}"
+
+out2="${TEST_DIR}/mcp2.out"
+err2="${TEST_DIR}/mcp2.err"
+# The feeder holds stdin open until the test finishes, so shutdown is driven
+# by the signal alone.
+(
+ printf '%s\n' "${INIT_REQ}"
+ printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"slow","arguments":{}}}'
+ for _ in $(seq 1 300); do [[ -f "${done2}" ]] && break; sleep 0.2; done
+) | jaiph mcp "${TEST_DIR}/tools_drain.jh" >"${out2}" 2>"${err2}" &
+E2E_SERVER_PID="$!"
+
+wait_for "drain call started" "${pid2}" "[0-9]"
+kill -TERM "${E2E_SERVER_PID}"
+wait_for "drain notice logged" "${err2}" "draining in-flight calls"
+# The call completes only after the signal: its second script step must still
+# find the generation's scripts on disk.
+touch "${gate2}"
+
+mcp2_exit=0
+wait "${E2E_SERVER_PID}" || mcp2_exit=$?
+E2E_SERVER_PID=""
+touch "${done2}"
+e2e::assert_equals "${mcp2_exit}" "0" "server 2 exits 0 after draining"
+
+fields="$(response_fields "${out2}" 3)" || { cat "${out2}" "${err2}" >&2; e2e::fail "no response for drained call"; }
+{ read -r p_iserror; read -r p_text; } <<< "${fields}"
+e2e::assert_equals "${p_iserror}" "false" "drained call completed successfully after SIGTERM"
+e2e::assert_equals "${p_text}" "drain-ok" "drained call ran its remaining script step after SIGTERM"
+
+pause_pid="$(cat "${pid2}")"
+if kill -0 "${pause_pid}" 2>/dev/null; then
+ e2e::fail "drained call's script process ${pause_pid} outlived the server"
+fi
+e2e::pass "no orphaned script process after drain"
+
+# ---------------------------------------------------------------------------
+# 3. Second SIGTERM cancels: child tree killed, error result, exit 0
+# ---------------------------------------------------------------------------
+e2e::section "second SIGTERM cancels in-flight calls without orphaning children"
+
+pid3="${TEST_DIR}/pause_cancel.pid"
+done3="${TEST_DIR}/done3"
+cat > "${TEST_DIR}/tools_cancel.jh" < "${pid3}"; sleep 300\`
+
+# Hangs until cancelled.
+workflow hang() {
+ run hang_forever()
+ return "unreachable"
+}
+EOF
+
+out3="${TEST_DIR}/mcp3.out"
+err3="${TEST_DIR}/mcp3.err"
+(
+ printf '%s\n' "${INIT_REQ}"
+ printf '%s\n' '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"hang","arguments":{}}}'
+ for _ in $(seq 1 300); do [[ -f "${done3}" ]] && break; sleep 0.2; done
+) | jaiph mcp "${TEST_DIR}/tools_cancel.jh" >"${out3}" 2>"${err3}" &
+E2E_SERVER_PID="$!"
+
+wait_for "hanging call started" "${pid3}" "[0-9]"
+kill -TERM "${E2E_SERVER_PID}"
+wait_for "drain notice logged" "${err3}" "draining in-flight calls"
+kill -TERM "${E2E_SERVER_PID}"
+wait_for "cancel notice logged" "${err3}" "cancelling in-flight calls"
+
+mcp3_exit=0
+wait "${E2E_SERVER_PID}" || mcp3_exit=$?
+E2E_SERVER_PID=""
+touch "${done3}"
+e2e::assert_equals "${mcp3_exit}" "0" "server 3 exits 0 after cancelling"
+
+fields="$(response_fields "${out3}" 5)" || { cat "${out3}" "${err3}" >&2; e2e::fail "no response for cancelled call"; }
+{ read -r p_iserror; read -r p_text; } <<< "${fields}"
+e2e::assert_equals "${p_iserror}" "true" "cancelled call settles with an isError result"
+# assert_contains: the narrative is timing-dependent ("terminated by signal
+# SIGINT" vs "failed (exit N)" if the runner fields the signal itself), so full
+# equality is not feasible; the response must at least name the failed workflow.
+e2e::assert_contains "${p_text}" "workflow hang" "cancelled call names the failed workflow"
+
+hang_pid="$(cat "${pid3}")"
+if kill -0 "${hang_pid}" 2>/dev/null; then
+ kill -9 "${hang_pid}" 2>/dev/null || true
+ e2e::fail "cancelled call's script process ${hang_pid} outlived the server (orphan)"
+fi
+e2e::pass "no orphaned script process after cancel"
diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts
index cde893c6..91991243 100644
--- a/src/cli/commands/mcp.ts
+++ b/src/cli/commands/mcp.ts
@@ -10,10 +10,11 @@ import { McpServer } from "../mcp/server";
import { callWorkflow } from "../exec/call";
import {
loadGeneration,
+ createGenerationTracker,
createSourceWatcher,
resolveStartupPosture,
WATCH_INTERVAL_MS,
- type GenerationState,
+ type GenerationTracker,
} from "../shared/generation";
import { VERSION } from "../../version";
@@ -79,7 +80,7 @@ export async function runMcp(rest: string[]): Promise {
const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-mcp-"));
let generation = 0;
- let state: GenerationState;
+ let generations: GenerationTracker;
try {
const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp");
if (!loaded.state) {
@@ -87,7 +88,7 @@ export async function runMcp(rest: string[]): Promise {
rmSync(tempRoot, { recursive: true, force: true });
return 1;
}
- state = loaded.state;
+ generations = createGenerationTracker(loaded.state);
} catch (err) {
log(err instanceof Error ? err.message : String(err));
rmSync(tempRoot, { recursive: true, force: true });
@@ -100,7 +101,7 @@ export async function runMcp(rest: string[]): Promise {
// JAIPH_INPLACE=1.
let dockerConfig: ReturnType["dockerConfig"];
try {
- const posture = resolveStartupPosture(state, inputAbs, workspaceRoot, log);
+ const posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
dockerConfig = posture.dockerConfig;
if (dockerConfig.enabled) {
if (posture.sandboxMode === "inplace") {
@@ -120,16 +121,20 @@ export async function runMcp(rest: string[]): Promise {
const server = new McpServer({
serverVersion: VERSION,
- getTools: () => state.tools,
- callTool: (spec, args, ctx) =>
- callWorkflow(
- state.callEnv,
+ getTools: () => generations.current().tools,
+ callTool: (spec, args, ctx) => {
+ // Bind the call to the generation live at start; the lease keeps its
+ // scripts dir alive until the call settles (deleted then if superseded).
+ const lease = generations.acquire();
+ return callWorkflow(
+ lease.state.callEnv,
dockerConfig,
spec.workflow,
spec.params.map((p) => args[p] ?? ""),
randomUUID(),
ctx,
- ),
+ ).finally(() => lease.release());
+ },
write: (message) => {
process.stdout.write(`${JSON.stringify(message)}\n`);
},
@@ -137,7 +142,10 @@ export async function runMcp(rest: string[]): Promise {
});
// Hot reload: poll every module source; on change re-validate and swap the
- // generation. Validation failures keep the previous generation serving.
+ // generation. Validation failures keep the previous generation serving. The
+ // tracker deletes the superseded generation's scripts dir only once its last
+ // in-flight call settles — a call started just before the reload still runs
+ // its remaining script steps from the generation it captured at start.
let reloading = false;
const onSourceChange = (): void => {
if (reloading) return;
@@ -150,12 +158,10 @@ export async function runMcp(rest: string[]): Promise {
for (const f of loaded.failures) log(` ${f}`);
return;
}
- const previousOutDir = state.callEnv.outDir;
- state = loaded.state;
- watcher.rewatch([...state.graph.modules.keys()]);
+ generations.swap(loaded.state);
+ watcher.rewatch([...loaded.state.graph.modules.keys()]);
server.notifyToolsChanged();
- log(`jaiph mcp: sources reloaded (${state.tools.length} tool(s))`);
- rmSync(previousOutDir, { recursive: true, force: true });
+ log(`jaiph mcp: sources reloaded (${loaded.state.tools.length} tool(s))`);
} catch (err) {
log(`jaiph mcp: reload failed; keeping the previous tool set: ${err instanceof Error ? err.message : String(err)}`);
} finally {
@@ -163,25 +169,37 @@ export async function runMcp(rest: string[]): Promise {
}
};
const watcher = createSourceWatcher(WATCH_INTERVAL_MS, onSourceChange);
- watcher.rewatch([...state.graph.modules.keys()]);
+ watcher.rewatch([...generations.current().graph.modules.keys()]);
- log(`jaiph mcp: serving ${state.tools.length} tool(s) from ${inputAbs} over stdio`);
+ log(`jaiph mcp: serving ${generations.current().tools.length} tool(s) from ${inputAbs} over stdio`);
return await new Promise((resolveExit) => {
+ let draining = false;
let settled = false;
- const shutdown = (code: number): void => {
+ const finish = (code: number): void => {
if (settled) return;
settled = true;
+ process.removeListener("SIGINT", onSignal);
+ process.removeListener("SIGTERM", onSignal);
watcher.stop();
rmSync(tempRoot, { recursive: true, force: true });
resolveExit(code);
};
-
- const rl = createInterface({ input: process.stdin, terminal: false });
// Handle requests concurrently: a long tools/call must not stall pings or
// further calls. JSON-RPC matches responses by id, so ordering is free to
// interleave; each outbound message is a single atomic stdout write.
const inFlight = new Set>();
+ // Drain-then-cancel shutdown: stop accepting input, let in-flight calls
+ // settle, then clean up. The temp root (scripts, graph files) must outlive
+ // every draining call — a run reads its scripts dir until it exits.
+ const drain = (): void => {
+ if (draining) return;
+ draining = true;
+ watcher.stop();
+ void Promise.allSettled([...inFlight]).then(() => finish(0));
+ };
+
+ const rl = createInterface({ input: process.stdin, terminal: false });
rl.on("line", (line) => {
const p = server.handleLine(line).catch((err) => {
log(`jaiph mcp: ${err instanceof Error ? err.message : String(err)}`);
@@ -189,10 +207,21 @@ export async function runMcp(rest: string[]): Promise {
inFlight.add(p);
void p.finally(() => inFlight.delete(p));
});
- rl.on("close", () => {
- void Promise.allSettled([...inFlight]).then(() => shutdown(0));
- });
- process.once("SIGINT", () => shutdown(0));
- process.once("SIGTERM", () => shutdown(0));
+ rl.on("close", drain);
+ const onSignal = (): void => {
+ if (!draining) {
+ log("jaiph mcp: shutting down; draining in-flight calls (signal again to cancel them)...");
+ rl.close();
+ drain();
+ } else {
+ // Second signal: kill every in-flight run's child process tree and, in
+ // Docker mode, force-remove its container; the calls then settle and
+ // the drain above finishes cleanup.
+ log("jaiph mcp: cancelling in-flight calls...");
+ server.cancelAll();
+ }
+ };
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
});
}
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index 508b2bf6..e9e046a1 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -8,10 +8,11 @@ import { resolveEnvPairs } from "../run/env";
import { callWorkflow } from "../exec/call";
import {
loadGeneration,
+ createGenerationTracker,
createSourceWatcher,
resolveStartupPosture,
WATCH_INTERVAL_MS,
- type GenerationState,
+ type GenerationTracker,
} from "../shared/generation";
import { ServeHandler } from "../serve/handler";
import { createHttpServer, listen } from "../serve/server";
@@ -52,13 +53,6 @@ function isLoopbackHost(host: string): boolean {
return LOOPBACK_HOSTS.has(host.toLowerCase());
}
-/** One in-flight generation: its state, live-run refcount, and superseded flag. */
-interface LiveGeneration {
- state: GenerationState;
- refs: number;
- superseded: boolean;
-}
-
export async function runServe(rest: string[]): Promise {
if (hasHelpFlag(rest)) {
process.stdout.write(SERVE_USAGE);
@@ -131,7 +125,7 @@ export async function runServe(rest: string[]): Promise {
const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-serve-"));
let generation = 0;
- let current: LiveGeneration;
+ let generations: GenerationTracker;
try {
const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve");
if (!loaded.state) {
@@ -139,7 +133,7 @@ export async function runServe(rest: string[]): Promise {
rmSync(tempRoot, { recursive: true, force: true });
return 1;
}
- current = { state: loaded.state, refs: 0, superseded: false };
+ generations = createGenerationTracker(loaded.state);
} catch (err) {
log(err instanceof Error ? err.message : String(err));
rmSync(tempRoot, { recursive: true, force: true });
@@ -149,7 +143,7 @@ export async function runServe(rest: string[]): Promise {
let dockerConfig: ReturnType["dockerConfig"];
let hostRunsRoot: string;
try {
- const posture = resolveStartupPosture(current.state, inputAbs, workspaceRoot, log);
+ const posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
dockerConfig = posture.dockerConfig;
hostRunsRoot = posture.hostRunsRoot;
if (dockerConfig.enabled) {
@@ -170,14 +164,6 @@ export async function runServe(rest: string[]): Promise {
return 1;
}
- // Delete a superseded generation's out dir only once its in-flight runs finish
- // — HTTP runs can outlive a reload (unlike MCP, where the client blocks).
- const maybeDeleteGeneration = (gen: LiveGeneration): void => {
- if (gen.superseded && gen.refs === 0) {
- rmSync(gen.state.callEnv.outDir, { recursive: true, force: true });
- }
- };
-
// Track in-flight run promises so shutdown can drain them.
const inFlightRuns = new Set>();
@@ -191,23 +177,19 @@ export async function runServe(rest: string[]): Promise {
// events/artifacts endpoints locate it by scanning the host runs root for
// the run id (works host- and Docker-side — the run dir is a host mount).
resolveRunDir: (record) => record.run_dir ?? findRunDir(hostRunsRoot, record.run_id),
- getTools: () => current.state.tools,
+ getTools: () => generations.current().tools,
callTool: (spec, args, runId, ctx) => {
- // Bind the run to the generation live at start; keep its scripts dir alive
- // until the run finishes, then delete it if the generation was superseded.
- const gen = current;
- gen.refs += 1;
+ // Bind the run to the generation live at start; the lease keeps its
+ // scripts dir alive until the run finishes (deleted then if superseded).
+ const lease = generations.acquire();
const p = callWorkflow(
- gen.state.callEnv,
+ lease.state.callEnv,
dockerConfig,
spec.workflow,
spec.params.map((pp) => args[pp] ?? ""),
runId,
ctx,
- ).finally(() => {
- gen.refs -= 1;
- maybeDeleteGeneration(gen);
- });
+ ).finally(() => lease.release());
const tracked = p.then(
() => undefined,
() => undefined,
@@ -232,12 +214,9 @@ export async function runServe(rest: string[]): Promise {
for (const f of loaded.failures) log(` ${f}`);
return;
}
- const prev = current;
- current = { state: loaded.state, refs: 0, superseded: false };
- watcher.rewatch([...current.state.graph.modules.keys()]);
- log(`jaiph serve: sources reloaded (${current.state.tools.length} workflow(s))`);
- prev.superseded = true;
- maybeDeleteGeneration(prev);
+ generations.swap(loaded.state);
+ watcher.rewatch([...loaded.state.graph.modules.keys()]);
+ log(`jaiph serve: sources reloaded (${loaded.state.tools.length} workflow(s))`);
} catch (err) {
log(`jaiph serve: reload failed; keeping the previous workflows: ${err instanceof Error ? err.message : String(err)}`);
} finally {
@@ -245,7 +224,7 @@ export async function runServe(rest: string[]): Promise {
}
};
const watcher = createSourceWatcher(WATCH_INTERVAL_MS, onSourceChange);
- watcher.rewatch([...current.state.graph.modules.keys()]);
+ watcher.rewatch([...generations.current().graph.modules.keys()]);
const httpServer = createHttpServer(handler, log);
let boundPort: number;
@@ -259,7 +238,7 @@ export async function runServe(rest: string[]): Promise {
}
const base = `http://${host}:${boundPort}`;
- log(`jaiph serve: listening on ${base} — API docs at ${base}/docs (${current.state.tools.length} workflow(s))`);
+ log(`jaiph serve: listening on ${base} — API docs at ${base}/docs (${generations.current().tools.length} workflow(s))`);
return await new Promise((resolveExit) => {
let draining = false;
diff --git a/src/cli/mcp/server.test.ts b/src/cli/mcp/server.test.ts
index 2fcf3961..417e3b85 100644
--- a/src/cli/mcp/server.test.ts
+++ b/src/cli/mcp/server.test.ts
@@ -286,6 +286,41 @@ test("notifications/cancelled arriving before the child spawns cancels once regi
assert.equal(cancelled, true, "a cancel that predates the child still terminates it");
});
+test("cancelAll: kills every in-flight run but still delivers each isError response", async () => {
+ // Shutdown drain-then-cancel: unlike a client cancellation, the calls are not
+ // marked cancelled, so each killed run must settle with a normal response.
+ const killed: number[] = [];
+ const calls = [deferred(), deferred()];
+ let callIndex = 0;
+ const { server, written } = makeServer({
+ callTool: async (_spec, _args, ctx) => {
+ const i = callIndex++;
+ ctx.onCancelHandle?.(() => {
+ killed.push(i);
+ calls[i].resolve({ text: "workflow build terminated by signal SIGINT", isError: true });
+ });
+ return calls[i].promise;
+ },
+ });
+ await initialize(server);
+ const p1 = server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 30, method: "tools/call", params: { name: "build", arguments: { target: "x" } } }),
+ );
+ const p2 = server.handleLine(
+ JSON.stringify({ jsonrpc: "2.0", id: 31, method: "tools/call", params: { name: "build", arguments: { target: "y" } } }),
+ );
+
+ server.cancelAll();
+ await Promise.all([p1, p2]);
+
+ assert.deepEqual(killed.sort(), [0, 1], "every in-flight run's terminator ran");
+ for (const id of [30, 31]) {
+ const res = written.find((m) => m.id === id) as { result: { isError: boolean } } | undefined;
+ assert.ok(res, `call ${id} still got a response (drain can settle)`);
+ assert.equal(res.result.isError, true, `call ${id} reports the termination as an error result`);
+ }
+});
+
test("notifications/cancelled for an unknown request id is a harmless no-op", async () => {
const { server, written } = makeServer();
await initialize(server);
diff --git a/src/cli/mcp/server.ts b/src/cli/mcp/server.ts
index e6ea44ed..88cf6d80 100644
--- a/src/cli/mcp/server.ts
+++ b/src/cli/mcp/server.ts
@@ -86,6 +86,17 @@ export class McpServer {
this.opts = opts;
}
+ /**
+ * Kill every in-flight call's run (second-signal shutdown: child + container
+ * teardown through each call's cancel handle). Unlike a client
+ * `notifications/cancelled`, the calls are NOT marked cancelled: each killed
+ * run settles with a normal `isError` response, so a shutdown drain awaiting
+ * those calls completes deterministically.
+ */
+ cancelAll(): void {
+ for (const entry of this.inFlight.values()) entry.cancelRun?.();
+ }
+
/** Tell connected clients the tool list changed (hot reload). */
notifyToolsChanged(): void {
if (!this.initialized) return;
diff --git a/src/cli/shared/generation.test.ts b/src/cli/shared/generation.test.ts
new file mode 100644
index 00000000..012807e3
--- /dev/null
+++ b/src/cli/shared/generation.test.ts
@@ -0,0 +1,108 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createGenerationTracker, type GenerationState } from "./generation";
+
+/**
+ * The tracker only reads `state.callEnv.outDir` (the directory it deletes), so
+ * the tests stub the rest of the generation state.
+ */
+function makeState(tempRoot: string, generation: number): GenerationState {
+ const outDir = join(tempRoot, `gen-${generation}`);
+ mkdirSync(outDir, { recursive: true });
+ return { callEnv: { outDir } } as GenerationState;
+}
+
+test("generation tracker: swap with no in-flight leases deletes the previous dir immediately", () => {
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-gen-test-"));
+ try {
+ const gen0 = makeState(tempRoot, 0);
+ const gen1 = makeState(tempRoot, 1);
+ const tracker = createGenerationTracker(gen0);
+ assert.equal(tracker.current(), gen0);
+
+ tracker.swap(gen1);
+ assert.equal(tracker.current(), gen1);
+ assert.equal(existsSync(gen0.callEnv.outDir), false, "idle superseded dir is deleted on swap");
+ assert.equal(existsSync(gen1.callEnv.outDir), true, "current dir survives");
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+});
+
+test("generation tracker: a lease keeps the superseded dir alive until it is released", () => {
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-gen-test-"));
+ try {
+ const gen0 = makeState(tempRoot, 0);
+ const gen1 = makeState(tempRoot, 1);
+ const tracker = createGenerationTracker(gen0);
+
+ const lease = tracker.acquire();
+ assert.equal(lease.state, gen0, "the lease pins the generation live at call start");
+
+ tracker.swap(gen1);
+ assert.equal(existsSync(gen0.callEnv.outDir), true, "superseded dir survives while the call runs");
+ assert.equal(tracker.current(), gen1, "new calls bind to the new generation");
+
+ lease.release();
+ assert.equal(existsSync(gen0.callEnv.outDir), false, "last release deletes the superseded dir");
+ assert.equal(existsSync(gen1.callEnv.outDir), true, "current dir is never deleted");
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+});
+
+test("generation tracker: concurrent leases across multiple swaps settle independently", () => {
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-gen-test-"));
+ try {
+ const gen0 = makeState(tempRoot, 0);
+ const gen1 = makeState(tempRoot, 1);
+ const gen2 = makeState(tempRoot, 2);
+ const tracker = createGenerationTracker(gen0);
+
+ const leaseA = tracker.acquire(); // gen0
+ tracker.swap(gen1);
+ const leaseB = tracker.acquire(); // gen1
+ tracker.swap(gen2);
+
+ assert.equal(leaseA.state, gen0);
+ assert.equal(leaseB.state, gen1);
+ assert.equal(existsSync(gen0.callEnv.outDir), true, "gen0 pinned by lease A");
+ assert.equal(existsSync(gen1.callEnv.outDir), true, "gen1 pinned by lease B");
+
+ // Out-of-order settle: the newer call finishes first.
+ leaseB.release();
+ assert.equal(existsSync(gen1.callEnv.outDir), false, "gen1 deleted when its last call settles");
+ assert.equal(existsSync(gen0.callEnv.outDir), true, "gen0 still pinned");
+
+ leaseA.release();
+ assert.equal(existsSync(gen0.callEnv.outDir), false, "gen0 deleted when its last call settles");
+ assert.equal(existsSync(gen2.callEnv.outDir), true, "only the current generation dir remains");
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+});
+
+test("generation tracker: release is idempotent and cannot free a dir another lease still holds", () => {
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-gen-test-"));
+ try {
+ const gen0 = makeState(tempRoot, 0);
+ const gen1 = makeState(tempRoot, 1);
+ const tracker = createGenerationTracker(gen0);
+
+ const leaseA = tracker.acquire();
+ const leaseB = tracker.acquire();
+ tracker.swap(gen1);
+
+ leaseA.release();
+ leaseA.release(); // double release must not decrement past its own lease
+ assert.equal(existsSync(gen0.callEnv.outDir), true, "lease B still holds the dir");
+
+ leaseB.release();
+ assert.equal(existsSync(gen0.callEnv.outDir), false, "deleted once the true last lease settles");
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/shared/generation.ts b/src/cli/shared/generation.ts
index 77d97bed..5f61fca2 100644
--- a/src/cli/shared/generation.ts
+++ b/src/cli/shared/generation.ts
@@ -1,4 +1,4 @@
-import { mkdirSync, unwatchFile, watchFile } from "node:fs";
+import { mkdirSync, rmSync, unwatchFile, watchFile } from "node:fs";
import { join } from "node:path";
import { loadModuleGraph, writeModuleGraph, type ModuleGraph } from "../../transpile/module-graph";
import { collectDiagnostics } from "../../transpile/validate";
@@ -75,6 +75,70 @@ export function loadGeneration(
};
}
+/** One live generation with the refcount that keeps its scripts dir alive. */
+interface LiveGeneration {
+ state: GenerationState;
+ refs: number;
+ superseded: boolean;
+}
+
+/** A lease on one generation, held by one in-flight call. */
+export interface GenerationLease {
+ /** The generation live when the call started; stable for the call's lifetime. */
+ state: GenerationState;
+ /** Settle the lease (idempotent). The last release of a superseded generation deletes its out dir. */
+ release: () => void;
+}
+
+export interface GenerationTracker {
+ /** The generation new calls should bind to. */
+ current: () => GenerationState;
+ /** Lease the current generation for one call; release when the call settles. */
+ acquire: () => GenerationLease;
+ /** Install a new generation; the previous one is deleted once its last lease settles. */
+ swap: (next: GenerationState) => void;
+}
+
+/**
+ * Refcounted generation lifecycle shared by `jaiph mcp` and `jaiph serve`. A
+ * hot reload must not delete the superseded generation's out dir (emitted
+ * scripts + serialized graph) while a call started under it is still running —
+ * the runner spawns each script step from that dir for the whole run. Calls
+ * lease the generation live at call start; a superseded generation's dir is
+ * removed only when its last lease is released (immediately on swap when idle).
+ */
+export function createGenerationTracker(initial: GenerationState): GenerationTracker {
+ let current: LiveGeneration = { state: initial, refs: 0, superseded: false };
+ const maybeDelete = (gen: LiveGeneration): void => {
+ if (gen.superseded && gen.refs === 0) {
+ rmSync(gen.state.callEnv.outDir, { recursive: true, force: true });
+ }
+ };
+ return {
+ current: () => current.state,
+ acquire(): GenerationLease {
+ const gen = current;
+ gen.refs += 1;
+ let released = false;
+ return {
+ state: gen.state,
+ release(): void {
+ if (released) return;
+ released = true;
+ gen.refs -= 1;
+ maybeDelete(gen);
+ },
+ };
+ },
+ swap(next: GenerationState): void {
+ const prev = current;
+ current = { state: next, refs: 0, superseded: false };
+ prev.superseded = true;
+ maybeDelete(prev);
+ },
+ };
+}
+
/**
* Resolve the shared startup sandbox posture for a workflow server (`jaiph mcp`
* and `jaiph serve`): the env-driven Docker selection (`jaiph run` semantics),
From c3ef50ded78c0dee682b8d0012c631990e0b07e5 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 14:24:35 +0200
Subject: [PATCH 09/24] Chore: gate engineer workflow on Claude capacity
Wait visibly between usage checks before selecting queue work, and keep Fable as the implementation model.
Co-authored-by: Cursor
---
.jaiph/engineer.jh | 47 +++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 42 insertions(+), 5 deletions(-)
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index fc604ecc..95af445c 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -194,12 +194,36 @@ workflow select_role(role_name) {
script task_text_has_header = `printf '%s\n' "$1" | grep -q '^## '`
+script check_claude_capacity = ```
+ local usage percent
+
+ if ! usage="$(claude -p "/usage")"; then
+ printf 'Failed to read Claude usage.\n' >&2
+ return 1
+ fi
+
+ percent="$(
+ printf '%s\n' "$usage" |
+ sed -nE 's/.*Current session: ([0-9]+)% used.*/\1/p' |
+ awk 'NR == 1 { print; exit }'
+ )"
+ case "$percent" in
+ ''|*[!0-9]*)
+ printf 'Could not parse current Claude session usage.\n' >&2
+ return 1
+ ;;
+ esac
+
+ printf 'Claude current session usage is %s%%.\n' "$percent"
+ (( percent <= 95 ))
+```
+
script first_line_task = ```
-local line
-line="$(printf '%s\n' "$1" | awk 'NR==1 { print; exit }')"
-line="${line#\#\# }"
-line="$(printf '%s\n' "$line" | sed 's/ *#[A-Za-z0-9_-]*//g')"
-printf '%s\n' "$line"
+ local line
+ line="$(printf '%s\n' "$1" | awk 'NR==1 { print; exit }')"
+ line="${line#\#\# }"
+ line="$(printf '%s\n' "$line" | sed 's/ *#[A-Za-z0-9_-]*//g')"
+ printf '%s\n' "$line"
```
workflow classify_role(task) {
@@ -299,8 +323,21 @@ workflow implement(task, role_name) {
"""
}
+workflow ensure_claude_usage() {
+ config {
+ # At ten-minute intervals this permits roughly 7 days of capacity checks.
+ run.recover_limit = 1000
+ }
+
+ run check_claude_capacity() recover (failure) {
+ logwarn "${failure}. Checking again in 10 minutes."
+ run `sleep 600`()
+ }
+}
+
workflow default(name) {
# ensure git.is_clean
+ run ensure_claude_usage()
const task = run queue.get_first_task()
const task_header = run first_line_task(task)
ensure queue.task_is_dev_ready(task)
From 1bd7c605408f1d07c305151dfc505d10e901c933 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 14:54:42 +0200
Subject: [PATCH 10/24] Fix: bound jaiph serve memory, output, and run
retention
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The serve concurrency cap limited active children but not process
memory, so a long-lived server could be exhausted three ways: the
in-memory run map grew forever, each completed run's result_text stayed
resident, and a single run's raw stdout/stderr/log capture accumulated
unbounded.
Add per-run UTF-8 byte caps (OutputCaps) threaded through the call
layer: attachOutputCollector stops accumulating stdout, stderr, and log
output at the cap and composeResult caps result_text as a backstop, with
overflow dropped and replaced by a deterministic truncation marker
(capBytes slices on a byte boundary to keep valid UTF-8). Defaults stay
effectively unbounded so jaiph mcp and direct callers are unchanged;
only jaiph serve passes finite caps via JAIPH_SERVE_MAX_OUTPUT_BYTES
(default 1 MiB).
Add completed-run retention: evictCompleted runs after every finalize,
dropping terminal records past JAIPH_SERVE_RETAIN_RUNS (default 500,
oldest first) and older than JAIPH_SERVE_RETAIN_AGE_SEC (default 86400,
0 disables). Active runs are never evicted, and eviction removes only
the in-memory record — durable journals and artifacts on disk remain the
operator's to prune.
Paginate GET /v1/runs via ?limit (default 100, clamped to 1000) and
?offset, returning {runs, total, limit, offset} so the response is never
unbounded and newest-first paging stays stable. Update the OpenAPI
document, docs, and add unit and e2e coverage.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +-
QUEUE.md | 18 -----
docs/cli.md | 3 +-
docs/env-vars.md | 3 +
docs/serve.md | 18 ++++-
e2e/tests/147_serve_http_api.sh | 11 +++
src/cli/commands/serve.ts | 54 ++++++++++++-
src/cli/exec/call.test.ts | 99 ++++++++++++++++++++++-
src/cli/exec/call.ts | 136 ++++++++++++++++++++++++++++----
src/cli/serve/handler.test.ts | 92 ++++++++++++++++++++-
src/cli/serve/handler.ts | 81 ++++++++++++++++++-
src/cli/serve/openapi.ts | 29 ++++++-
12 files changed, 498 insertions(+), 50 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2fd07b4..53cc06a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
## Summary
-- **Serve workflows over HTTP:** run `jaiph serve ./tools.jh` to expose a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so a CI job, a Kubernetes deployment, or any HTTP client can invoke tested workflows and inspect their runs — no MCP client or local jaiph install required. Bearer-token auth, a concurrency cap, and the same durable `.jaiph/runs/` records as `jaiph run` are built in.
+- **Serve workflows over HTTP:** run `jaiph serve ./tools.jh` to expose a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so a CI job, a Kubernetes deployment, or any HTTP client can invoke tested workflows and inspect their runs — no MCP client or local jaiph install required. Bearer-token auth, a concurrency cap, per-run output caps, bounded run retention, and the same durable `.jaiph/runs/` records as `jaiph run` are built in, so a long-lived service stays within a bounded memory footprint.
- **Export traces to an OTLP collector:** every run now exports one OpenTelemetry span tree (workflow → steps → prompts) to any OTLP/HTTP collector — a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog — enabled by the standard `OTEL_EXPORTER_OTLP_ENDPOINT`. Export is host-side and end-of-run (it reads the run's already credential-redacted `run_summary.jsonl`), adds no runtime dependencies, and is never load-bearing: an unreachable or erroring collector produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched.
@@ -12,6 +12,8 @@
## All changes
+- **Fix — bound `jaiph serve` memory: per-run output caps, completed-run retention, and paginated listing:** `jaiph serve`'s concurrency cap limits *active* children but not process memory, so over a long-lived server an authenticated caller could exhaust it three ways — the in-memory run map grew forever, each completed run's `result_text` stayed resident forever, and a single run's raw stdout/stderr/`log` capture accumulated unbounded. All three are now bounded. **Per-run output caps:** a new `OutputCaps` (`src/cli/exec/call.ts`) threads a UTF-8 byte cap through the call layer; `attachOutputCollector` keeps per-stream byte counters and one-shot "cut" flags so stdout, stderr, and collected `log` output each stop accumulating at the cap, and `composeResult` caps the composed `result_text` as a final backstop. Overflow is dropped and replaced with a deterministic `TRUNCATION_MARKER` (`[jaiph: output truncated — exceeded the configured byte cap]`); the new `capBytes` helper slices on a byte boundary and drops a trailing partial multibyte char so the head stays valid UTF-8. `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) sets one value applied **independently** to each of the four channels. The default caps are effectively unbounded (`Number.MAX_SAFE_INTEGER` via `DEFAULT_OUTPUT_CAPS`), so `jaiph mcp` and direct callers are byte-for-byte unchanged — only `jaiph serve` passes finite caps. **Completed-run retention:** `ServeHandler.evictCompleted` (`src/cli/serve/handler.ts`), called after every finalize, drops terminal records past `JAIPH_SERVE_RETAIN_RUNS` (default `500`, oldest terminal `order` evicted first) and older than `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`; `0` disables age eviction). **Active (`running`) runs are never evicted**, and eviction removes only the in-memory record — the durable `run_summary.jsonl` journal and `artifacts/` on disk are untouched and remain the operator's to prune. **Bounded listing:** `GET /v1/runs` is now paginated via `?limit` (default `100`, clamped to `1000` by `clampInt`) and `?offset`, returning `{runs, total, limit, offset}`; a hostile `?limit=` can never widen the page past the maximum, so the response is never unbounded, and the monotonic unique `order` field keeps newest-first paging stable. The OpenAPI document (`src/cli/serve/openapi.ts`) gains the `limit`/`offset` parameters and the `total`/`limit`/`offset` response fields. `jaiph serve` startup parses the three env vars with a shared `intEnv` validator (a non-integer or out-of-range value prints a diagnostic to stderr and exits `1`) and logs the effective memory bounds plus the durable-artifact operator caveat. Tests: `src/cli/exec/call.test.ts` (`capBytes` verbatim vs. marked overflow and no U+FFFD split; `composeResult` caps a runaway success return and a runaway failure narrative; `attachOutputCollector` bounds stdout/stderr/logs each with a marker), `src/cli/serve/handler.test.ts` (count retention evicts only the oldest terminal records; an active run survives even past the budget; age retention evicts once past the window; pagination is bounded, newest-first stable, and reports `total`; `limit` is clamped to the maximum), and `e2e/tests/147_serve_http_api.sh` (`?limit=1` returns exactly one record while echoing the limit and full total). Concurrency, cancellation, SSE, and artifact tests continue to pass. Docs: a new [Bound memory over a long-lived server](docs/serve.md#8-bound-memory-over-a-long-lived-server) section in [Serve workflows over HTTP](docs/serve.md), the three `JAIPH_SERVE_*` rows in [Environment variables](docs/env-vars.md), and the paginated `GET /v1/runs` row plus a memory-bounds bullet in the `jaiph serve` section of [CLI](docs/cli.md#jaiph-serve).
+
- **Fix — keep `jaiph mcp` generations alive while calls are in flight, and drain before shutdown:** `jaiph mcp` handles tool calls concurrently, but a hot reload used to delete the previous generation's out dir (emitted scripts + serialized graph) the instant it swapped — `rmSync(previousOutDir, …)` ran synchronously in the reload handler — so a call that started just before the reload lost the scripts its remaining steps spawn from, and signal shutdown removed the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounted its generations (HTTP runs can outlive a reload); MCP now gets the same guarantee from a **shared** lifecycle. The `LiveGeneration` refcount model was extracted from `src/cli/commands/serve.ts` into `createGenerationTracker` in `src/cli/shared/generation.ts` and is now used by both commands: each call `acquire()`s a **lease** on the generation live at call start (`lease.state` is stable for the call's lifetime), and a superseded generation's out dir is deleted only when its **last lease releases** — immediately on swap when idle, otherwise when the final in-flight call settles. A call spanning one or more reloads therefore always runs its remaining script steps from the generation it captured, and no generation directory is left behind after all calls settle. **Shutdown is now drain-then-cancel** (matching `jaiph serve`): stdin closing or the **first** `SIGINT`/`SIGTERM` stops accepting input and awaits every in-flight call before removing the temp root and exiting `0` — a draining call keeps its scripts on disk until it settles. A **second** signal cancels instead of waiting: the new `McpServer.cancelAll()` (`src/cli/mcp/server.ts`) invokes each in-flight call's cancel handle, terminating its run's child process tree (`SIGINT`, then `SIGKILL` after a grace period) and, in Docker mode, force-removing its container (`docker rm -f`), so no child process or container is orphaned; unlike a client `notifications/cancelled`, the calls are **not** marked cancelled — each killed run settles with a normal `isError` response, so the drain awaiting them completes deterministically and the server still exits `0`. Tests: new `src/cli/shared/generation.test.ts` (lease pins the generation across a swap; last release deletes the superseded dir; concurrent leases across multiple swaps settle out-of-order and leave only the current dir; release is idempotent and cannot free a dir another lease still holds), `src/cli/mcp/server.test.ts` (`cancelAll` kills every in-flight run yet still delivers each `isError` response), and a new `e2e/tests/149_mcp_generation_lifecycle.sh` (wired into `e2e/test_all.sh`): a slow call started before a reload returns the **old** generation's value while a post-reload call returns the **new** one and stdin-close drains both; SIGTERM drains an in-flight call to completion with no orphaned script process; a second SIGTERM cancels with an `isError` result and no orphan. Docs: updated `jaiph mcp` shutdown/exit behavior and a generation-lease note in [CLI](docs/cli.md#jaiph-mcp), an in-flight-call note under hot reload and a new "Shutdown (drain, then cancel)" section in [Serve workflows as MCP tools](docs/mcp.md).
- **Fix — redact credentials from every returned workflow result, not just the durable journal:** `jaiph serve`'s `result_text` and a `jaiph mcp` tool error could return a secret that events, OTLP, and Sentry all redacted. The failure text was built in `composeResult` (`src/cli/exec/call.ts`) from the run's **live** captured output — failed-step detail, raw stderr/stdout, and collected `log` messages — whereas only the durable `run_summary.jsonl` copies were credential-redacted by `RuntimeEventEmitter`; a workflow that echoed a `*_API_KEY` / `*_TOKEN` / `*_SECRET` value to a stream and then failed leaked it verbatim through `?wait=true`, `GET /v1/runs/{id}`, and MCP tool results. The redaction rule is now **one shared helper**, `redactCredentials` (extracted from `RuntimeEventEmitter` into the new `src/runtime/kernel/redact.ts` — same credential-key suffixes, same ≥8-char threshold), imported by both the journal writer and `composeResult`. `composeResult` redacts the fully assembled failure text once, so step detail, raw streams, and logs all pass the same boundary regardless of which branch contributed them, and it does not rely on the (unredacted-at-source) live `__JAIPH_EVENT__` stream. This is the single boundary both `jaiph serve` (`result_text`) and `jaiph mcp` (tool results) return through; on the Docker path the `--env` passthrough (which flows through `DockerSpawnOptions.extraEnv`, not `runtimeEnv`) is merged back in so those values redact too. **Successful return values are unchanged:** a workflow's `return` is intentional API output, not diagnostic capture, and is returned verbatim (its `log`-output fallback, being diagnostic capture, is redacted). Tests: `src/cli/exec/call.test.ts`, and integration coverage that a failing workflow echoing a credential to both streams never exposes the value through `?wait=true`, `GET /v1/runs/{id}` (`integration/serve-server.test.ts`), or an MCP tool result (`integration/mcp-server.test.ts`) while `[REDACTED]` and the failed-step diagnostics are retained; the existing event-journal, OTLP, and Sentry redaction tests still pass. Docs: [Serve workflows over HTTP](docs/serve.md), [Call a tool and read the result](docs/mcp.md), and the redaction section of [Architecture](docs/architecture.md).
diff --git a/QUEUE.md b/QUEUE.md
index d5d84b46..f70763ed 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -32,24 +32,6 @@ Acceptance:
- The pod runs as non-root and the test fails if privilege escalation, capabilities, or the default service-account token are reintroduced.
- `kubectl apply --dry-run=client` remains a fast schema check, but is not the only deployment test.
-## Bound HTTP service memory, output, and run retention #dev-ready
-
-The concurrency cap limits active children but not process memory. The in-memory run map grows forever, completed `result_text` stays resident forever, and raw child stdout/stderr/log accumulation is not bounded. An authenticated caller can exhaust a long-lived service.
-
-Scope:
-
-- Add explicit byte caps for collected stdout, stderr, logs, and public `result_text`, with a visible truncation marker.
-- Add configurable completed-run retention by count and age; active runs must never be evicted.
-- Paginate `GET /v1/runs` with a bounded default and maximum page size.
-- Keep durable journals/artifacts independent from in-memory eviction and document their disk-retention responsibility.
-
-Acceptance:
-
-- Runs producing output beyond every cap keep the process within a tested memory bound and return deterministic truncation markers.
-- More completed runs than the configured limit evicts only the oldest terminal records.
-- Run listing cannot return an unbounded response, and pagination order is stable.
-- Concurrency, cancellation, SSE, and artifact tests continue to pass.
-
## Make HTTP request, event, and artifact I/O scale with bytes transferred #dev-ready
The HTTP layer still performs avoidable whole-resource work: an aborted request body can leave `readBody` pending, SSE repeatedly scans historical run directories until `run_dir` is known, each SSE poll rereads the whole journal, and artifact downloads load the complete file into memory.
diff --git a/docs/cli.md b/docs/cli.md
index b13504d2..606c8ed7 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -376,7 +376,7 @@ Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to s
| `GET /docs` | none | Swagger UI shell (loads `swagger-ui-dist` from a pinned, SRI-verified CDN). |
| `GET /v1/workflows` | bearer | `{workflows: [{name, description, params}]}`. |
| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. |
-| `GET /v1/runs` | bearer | Runs started by this process (in-memory), newest first. |
+| `GET /v1/runs` | bearer | Runs started by this process (in-memory), newest first. Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. |
| `GET /v1/runs/{id}` | bearer | The run object. `404` unknown. |
| `GET /v1/runs/{id}/events` | bearer | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot; `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
| `GET /v1/runs/{id}/artifacts` | bearer | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. |
@@ -389,6 +389,7 @@ The run object is `{run_id, workflow, status, started_at, ended_at, exit_status,
- `JAIPH_SERVE_TOKEN` (env only) enables a bearer token required on every `/v1/*` request, compared in constant time. `/healthz`, `/openapi.json`, and `/docs` stay unauthenticated (schema metadata only). On loopback the token is optional; binding a non-loopback `--host` without it is a startup error.
- `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs; requests beyond it get `429`.
+- Memory bounds keep a long-lived server from growing without limit: `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) caps collected stdout, stderr, log output, and the resident `result_text` per run (overflow dropped with a truncation marker); `JAIPH_SERVE_RETAIN_RUNS` (default `500`) and `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`, `0` disables) bound how many completed runs stay in the in-memory registry, evicting the oldest terminal records first. **Active runs are never evicted**, and eviction drops only the in-memory record — durable `.jaiph/runs` journals and artifacts persist on disk and are the operator's to prune. See [Serve workflows over HTTP](serve.md#8-bound-memory-over-a-long-lived-server).
- Execution and hot reload are identical to `jaiph mcp`; a superseded generation's scripts dir survives until its in-flight HTTP runs finish.
## Environment variables
diff --git a/docs/env-vars.md b/docs/env-vars.md
index 94b6e83b..d12af738 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -79,6 +79,9 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_RUNS_DIR_LOCKED` | internal | bool | — | — | Lock flag for `JAIPH_RUNS_DIR`. |
| `JAIPH_SCRIPTS` | internal | path | — | — | Directory of emitted `script` files for this run. Set after `buildScripts()`. Any parent-shell value is cleared before launch. |
| `JAIPH_SERVE_MAX_CONCURRENT` | host | int | `4` | — | `jaiph serve` — cap on simultaneously-running workflows; requests beyond it get HTTP `429`. Must be a positive integer. |
+| `JAIPH_SERVE_MAX_OUTPUT_BYTES` | host | int | `1048576` (1 MiB) | — | `jaiph serve` — per-run byte cap applied independently to collected stdout, stderr, log output, and the public `result_text`. Output beyond the cap is dropped with a deterministic truncation marker, bounding one run's memory. Must be a positive integer. |
+| `JAIPH_SERVE_RETAIN_AGE_SEC` | host | int | `86400` (24h) | — | `jaiph serve` — max age (seconds, from `ended_at`) of a completed run kept in the in-memory registry; older terminal records are evicted. `0` disables age eviction. Active runs are never evicted; durable `.jaiph/runs` artifacts are unaffected. Must be `>= 0`. |
+| `JAIPH_SERVE_RETAIN_RUNS` | host | int | `500` | — | `jaiph serve` — max completed runs kept in the in-memory registry; beyond it the oldest terminal records are evicted first. Active runs are never evicted; durable `.jaiph/runs` artifacts are unaffected. Must be a positive integer. |
| `JAIPH_SERVE_TOKEN` | host | string | — | — | `jaiph serve` — bearer token required on every `/v1/*` request (constant-time compared). Unset leaves `/v1/*` open on loopback; binding a non-loopback `--host` without it is a startup error. `/healthz`, `/openapi.json`, `/docs` are always unauthenticated. |
| `JAIPH_SKILL_PATH` | host | path | — | — | When set and the path exists, `jaiph init` writes `.jaiph/SKILL.md` from that file. Otherwise the CLI walks an install-relative search. |
| `JAIPH_SOURCE_ABS` | internal | path | — | — | Absolute path to the entry `.jh` file. Set by the CLI before spawning the runner. |
diff --git a/docs/serve.md b/docs/serve.md
index c820d692..659f632f 100644
--- a/docs/serve.md
+++ b/docs/serve.md
@@ -48,7 +48,7 @@ curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
-H 'content-type: application/json' -d '{"name":"world"}' | jq
```
-The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. Failure narratives are credential-redacted (`[REDACTED]`) the same way as the event journal; the `return` value of a successful run is intentional API output and returned verbatim. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first), and stop one with `POST /v1/runs/{id}/cancel`.
+The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}`. `result_text` is the same content an MCP client sees — the workflow's `return` value, or its failure narrative. Failure narratives are credential-redacted (`[REDACTED]`) the same way as the event journal; the `return` value of a successful run is intentional API output and returned verbatim. **A workflow failure is not an HTTP error:** a failed run comes back `200`/`202` with `status: "failed"` and a `run dir:` pointer in `result_text`. Poll `GET /v1/runs/{id}` for an async run, list them with `GET /v1/runs` (newest first, paginated — `?limit=` defaults to 100 and is clamped to 1000, `?offset=` skips that many; the response carries `{runs, total, limit, offset}`), and stop one with `POST /v1/runs/{id}/cancel`.
## 4. Watch a run as it executes
@@ -96,6 +96,16 @@ curl -s http://host:8080/v1/workflows -H 'authorization: Bearer secret' | jq
Binding a non-loopback `--host` **without** `JAIPH_SERVE_TOKEN` is a startup error — the server refuses to expose unauthenticated arbitrary shell. Cap simultaneous runs with `JAIPH_SERVE_MAX_CONCURRENT` (default `4`); requests beyond the cap get `429`. See [Environment variables](env-vars.md) for both.
+## 8. Bound memory over a long-lived server
+
+The concurrency cap limits *active* children, not process memory. A long-lived server accumulates run state, so three bounds keep it from growing without limit — all overridable via [environment variables](env-vars.md):
+
+- **Per-run output caps.** `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) caps collected stdout, stderr, log output, and the resident `result_text` independently. Output beyond a cap is dropped and marked with a deterministic `[jaiph: output truncated — exceeded the configured byte cap]` marker, so a run that emits gigabytes still costs bounded memory and returns a self-describing result.
+- **Completed-run retention.** The in-memory run registry keeps at most `JAIPH_SERVE_RETAIN_RUNS` completed runs (default 500) and drops any completed run older than `JAIPH_SERVE_RETAIN_AGE_SEC` (default 24h). The oldest terminal records evict first; **active runs are never evicted.**
+- **Bounded listing.** `GET /v1/runs` is paginated (`?limit=`, `?offset=`) with a default of 100 and a hard maximum of 1000 per page, so the endpoint can never return an unbounded response.
+
+**Eviction is in-memory only.** Dropping a run from the registry does **not** delete its durable `.jaiph/runs//run_summary.jsonl` journal or published `artifacts/` — those persist on disk (via `JAIPH_RUNS_DIR`, an `emptyDir` or PVC under Kubernetes) and are **the operator's to prune**. Once a run is evicted its API endpoints (`GET /v1/runs/{id}`, `/events`, `/artifacts`) return `404`; read the durable artifacts from the filesystem instead.
+
Execution honors the same env-driven sandbox as [`jaiph run`](cli.md#jaiph-run) and [Run in a Docker sandbox](/how-to/sandbox-run): a Docker sandbox with an isolated workspace by default. `JAIPH_INPLACE=1` keeps the sandbox but binds the real workspace read-write so run effects land live; `JAIPH_UNSAFE=true` runs on the host with no sandbox at all. Publish files a run produces with [artifacts](/how-to/artifacts). Editing a served source hot-reloads the workflow set (and the OpenAPI document) with no restart; runs already in flight keep running.
## Verification
@@ -108,6 +118,10 @@ curl -s http://127.0.0.1:5247/healthz | jq -e '.status == "ok"'
curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \
-H 'content-type: application/json' -d '{"name":"ok"}' \
| jq -e '.status == "succeeded" and (.run_dir | length > 0)'
+
+# The run listing is bounded: a hostile limit is clamped to at most 1000 records.
+curl -s 'http://127.0.0.1:5247/v1/runs?limit=100000' \
+ | jq -e '.limit == 1000 and (.runs | length) <= 1000'
```
Both `jq -e` checks exit `0` when the contract holds. The run's durable record is under `.jaiph/runs/…/run_summary.jsonl` (`run_dir` in the response), the same artifact layout as `jaiph run`.
@@ -117,4 +131,4 @@ Both `jq -e` checks exit `0` when the contract holds. The run's durable record i
- [CLI — `jaiph serve`](cli.md#jaiph-serve) — flag and endpoint reference.
- [Serve workflows as MCP tools](mcp.md) — the stdio sibling with the same exposure rules.
- [Run in a Docker sandbox](/how-to/sandbox-run) — the execution sandbox HTTP runs use.
-- [Environment variables](env-vars.md) — `JAIPH_SERVE_TOKEN`, `JAIPH_SERVE_MAX_CONCURRENT`, and the sandbox controls.
+- [Environment variables](env-vars.md) — `JAIPH_SERVE_TOKEN`, `JAIPH_SERVE_MAX_CONCURRENT`, the `JAIPH_SERVE_MAX_OUTPUT_BYTES` / `JAIPH_SERVE_RETAIN_RUNS` / `JAIPH_SERVE_RETAIN_AGE_SEC` memory bounds, and the sandbox controls.
diff --git a/e2e/tests/147_serve_http_api.sh b/e2e/tests/147_serve_http_api.sh
index cdd90641..3a82944e 100755
--- a/e2e/tests/147_serve_http_api.sh
+++ b/e2e/tests/147_serve_http_api.sh
@@ -131,6 +131,17 @@ print(",".join(names))
')"
e2e::assert_equals "${workflows}" "boom,greet,make_artifact" "GET /v1/workflows lists all workflows"
+# --- GET /v1/runs is paginated (bounded listing) ---
+# Two runs exist by now (greet, boom); ?limit=1 must return exactly one record,
+# echo the requested limit, and report the full total so the response can never
+# be unbounded. Field-level checks: run objects carry volatile run_dir/timestamps.
+runs_page="$(curl -s "${base}/v1/runs?limit=1" | python3 -c '
+import json, sys
+d = json.load(sys.stdin)
+print("%d,%d,%d" % (len(d["runs"]), d["limit"], d["total"]))
+')"
+e2e::assert_equals "${runs_page}" "1,1,2" "GET /v1/runs?limit=1 returns a bounded page with a total count"
+
# --- GET /v1/runs/{id}/events (NDJSON) mirrors the durable journal ---
# The greet run above returned a run object; re-run it capturing the id, then
# byte-compare the events endpoint against the on-disk run_summary.jsonl.
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index e9e046a1..493af3b7 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -5,7 +5,7 @@ import { detectWorkspaceRoot } from "../shared/paths";
import { findRunDir } from "../shared/errors";
import { hasHelpFlag, parseArgs } from "../shared/usage";
import { resolveEnvPairs } from "../run/env";
-import { callWorkflow } from "../exec/call";
+import { callWorkflow, type OutputCaps } from "../exec/call";
import {
loadGeneration,
createGenerationTracker,
@@ -21,6 +21,25 @@ import { VERSION } from "../../version";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 5247;
const DEFAULT_MAX_CONCURRENT = 4;
+/** Keep the newest 500 completed runs resident; older terminal records evict. */
+const DEFAULT_RETAIN_RUNS = 500;
+/** Evict a completed run 24h after it ended (0 would disable age eviction). */
+const DEFAULT_RETAIN_AGE_SEC = 24 * 60 * 60;
+/** 1 MiB per stream / log buffer / result_text; bounds one run's memory. */
+const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
+
+/**
+ * Parse an integer env var, returning the fallback when unset. Throws a
+ * diagnosable error (caught by the caller) when set but not an integer `>= min`.
+ */
+function intEnv(raw: string | undefined, name: string, fallback: number, min: number): number {
+ if (raw === undefined) return fallback;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < min) {
+ throw new Error(`${name} must be an integer >= ${min}, got "${raw}"`);
+ }
+ return n;
+}
const SERVE_USAGE =
"Usage: jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... \n\n" +
@@ -38,7 +57,11 @@ const SERVE_USAGE =
"Auth: set JAIPH_SERVE_TOKEN to require `Authorization: Bearer ` on every\n" +
"/v1/* request (/healthz, /openapi.json, /docs stay open). Binding a non-loopback\n" +
"host without the token set is a startup error. Cap concurrent runs with\n" +
- "JAIPH_SERVE_MAX_CONCURRENT (default 4).\n\n" +
+ "JAIPH_SERVE_MAX_CONCURRENT (default 4). Bound memory with JAIPH_SERVE_MAX_OUTPUT_BYTES\n" +
+ "(per-run stdout/stderr/log/result cap, default 1 MiB), JAIPH_SERVE_RETAIN_RUNS\n" +
+ "(completed runs kept in memory, default 500), and JAIPH_SERVE_RETAIN_AGE_SEC\n" +
+ "(max completed-run age, default 86400; 0 disables). GET /v1/runs is paginated\n" +
+ "(?limit default 100, max 1000; ?offset).\n\n" +
" --host listen address (default: 127.0.0.1)\n" +
" --port listen port (default: 5247)\n" +
" --workspace workspace root for import resolution (default: auto-detect)\n" +
@@ -118,6 +141,25 @@ export async function runServe(rest: string[]): Promise {
maxConcurrent = n;
}
+ // Memory bounds: retained completed runs (count + age) and per-run output caps.
+ let retainRuns: number;
+ let retainAgeSec: number;
+ let maxOutputBytes: number;
+ try {
+ retainRuns = intEnv(process.env.JAIPH_SERVE_RETAIN_RUNS, "JAIPH_SERVE_RETAIN_RUNS", DEFAULT_RETAIN_RUNS, 1);
+ retainAgeSec = intEnv(process.env.JAIPH_SERVE_RETAIN_AGE_SEC, "JAIPH_SERVE_RETAIN_AGE_SEC", DEFAULT_RETAIN_AGE_SEC, 0);
+ maxOutputBytes = intEnv(process.env.JAIPH_SERVE_MAX_OUTPUT_BYTES, "JAIPH_SERVE_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT_BYTES, 1);
+ } catch (err) {
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
+ return 1;
+ }
+ const outputCaps: OutputCaps = {
+ stdout: maxOutputBytes,
+ stderr: maxOutputBytes,
+ logs: maxOutputBytes,
+ resultText: maxOutputBytes,
+ };
+
// All logs go to stderr — stdout stays clean for scripting.
const log = (line: string): void => {
process.stderr.write(`${line}\n`);
@@ -172,6 +214,8 @@ export async function runServe(rest: string[]): Promise {
serverTitle: `jaiph — ${basename(inputAbs)}`,
token,
maxConcurrent,
+ retainRuns,
+ retainAgeSec,
now: () => new Date().toISOString(),
// A run's dir is only recorded on its object at finalize; while it runs the
// events/artifacts endpoints locate it by scanning the host runs root for
@@ -189,6 +233,7 @@ export async function runServe(rest: string[]): Promise {
spec.params.map((pp) => args[pp] ?? ""),
runId,
ctx,
+ outputCaps,
).finally(() => lease.release());
const tracked = p.then(
() => undefined,
@@ -239,6 +284,11 @@ export async function runServe(rest: string[]): Promise {
const base = `http://${host}:${boundPort}`;
log(`jaiph serve: listening on ${base} — API docs at ${base}/docs (${generations.current().tools.length} workflow(s))`);
+ log(
+ `jaiph serve: memory bounds — retain ${retainRuns} completed run(s)` +
+ `${retainAgeSec > 0 ? ` up to ${retainAgeSec}s old` : ""}, ${maxOutputBytes} output bytes/run; ` +
+ "durable .jaiph/runs artifacts are pruned separately (operator responsibility).",
+ );
return await new Promise((resolveExit) => {
let draining = false;
diff --git a/src/cli/exec/call.test.ts b/src/cli/exec/call.test.ts
index 410101e3..4c8a14b3 100644
--- a/src/cli/exec/call.test.ts
+++ b/src/cli/exec/call.test.ts
@@ -1,9 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import type { ChildProcess } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { composeResult, type CollectedOutput } from "./call";
+import {
+ attachOutputCollector,
+ capBytes,
+ composeResult,
+ TRUNCATION_MARKER,
+ type CollectedOutput,
+ type OutputCaps,
+} from "./call";
const SECRET = "supersecretvalue123";
const ENV: NodeJS.ProcessEnv = { LEAK_API_KEY: SECRET };
@@ -73,3 +82,91 @@ test("composeResult redacts the log-fallback success text (diagnostic capture)",
assert.equal(result.isError, false);
assert.equal(result.text, "log saw [REDACTED]");
});
+
+// === output byte caps (bounded memory) ===
+
+const SMALL_CAPS: OutputCaps = { stdout: 64, stderr: 64, logs: 32, resultText: 128 };
+
+test("capBytes returns short input verbatim and marks truncation on overflow", () => {
+ assert.equal(capBytes("hello", 128), "hello");
+ const capped = capBytes("x".repeat(10_000), 100);
+ assert.ok(Buffer.byteLength(capped) <= 100 + Buffer.byteLength(TRUNCATION_MARKER), "bounded to cap + marker");
+ assert.ok(capped.endsWith(TRUNCATION_MARKER), "marker appended");
+ assert.equal(capped.slice(0, 100), "x".repeat(100), "head preserved up to the cap");
+});
+
+test("capBytes does not split a trailing multibyte char (no U+FFFD)", () => {
+ // "€" is 3 UTF-8 bytes; a cap landing mid-char must drop the partial char.
+ const capped = capBytes("€€€€", 4);
+ assert.ok(!capped.includes("�"), "no replacement char leaks into the head");
+ assert.equal(capped, "€" + TRUNCATION_MARKER);
+});
+
+test("composeResult caps result_text on a runaway failure and returns a deterministic marker", () => {
+ const huge = "z".repeat(5 * 1024 * 1024); // 5 MiB of stdout from a hostile run
+ const result = composeResult("boom", collected({ rawStdout: huge }), FAIL, undefined, undefined, ENV, SMALL_CAPS);
+ assert.equal(result.isError, true);
+ assert.ok(
+ Buffer.byteLength(result.text) <= SMALL_CAPS.resultText + Buffer.byteLength(TRUNCATION_MARKER),
+ `result_text stays within the cap (was ${Buffer.byteLength(result.text)} bytes)`,
+ );
+ assert.ok(result.text.endsWith(TRUNCATION_MARKER), "truncation marker is deterministic and present");
+});
+
+test("composeResult caps a runaway successful return value", () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-call-cap-"));
+ try {
+ writeFileSync(join(runDir, "return_value.txt"), "y".repeat(1024));
+ const result = composeResult("ok", collected({}), OK, runDir, undefined, ENV, SMALL_CAPS);
+ assert.equal(result.isError, false);
+ assert.ok(Buffer.byteLength(result.text) <= SMALL_CAPS.resultText + Buffer.byteLength(TRUNCATION_MARKER));
+ assert.ok(result.text.endsWith(TRUNCATION_MARKER));
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+class FakeStream extends EventEmitter {
+ setEncoding(): void {}
+}
+
+function fakeChild(): { child: ChildProcess; stdout: FakeStream; stderr: FakeStream } {
+ const stdout = new FakeStream();
+ const stderr = new FakeStream();
+ return { child: { stdout, stderr } as unknown as ChildProcess, stdout, stderr };
+}
+
+test("attachOutputCollector bounds stdout, stderr, and logs, each with a marker", () => {
+ const { child, stdout, stderr } = fakeChild();
+ const { data, drain } = attachOutputCollector(child, undefined, SMALL_CAPS);
+
+ // stdout: two 50-byte chunks — the second overflows the 64-byte cap.
+ stdout.emit("data", "a".repeat(50));
+ stdout.emit("data", "b".repeat(50));
+ stdout.emit("data", "c".repeat(50)); // ignored once cut
+ assert.ok(
+ Buffer.byteLength(data.rawStdout) <= SMALL_CAPS.stdout + Buffer.byteLength(TRUNCATION_MARKER),
+ "rawStdout bounded",
+ );
+ assert.ok(data.rawStdout.startsWith("a".repeat(50)));
+ assert.ok(data.rawStdout.endsWith(TRUNCATION_MARKER));
+ assert.ok(!data.rawStdout.includes("cccc"), "post-cut chunks are dropped");
+
+ // stderr raw (non-event) lines: two 50-byte lines overflow the 64-byte cap.
+ stderr.emit("data", `${"p".repeat(50)}\n${"q".repeat(50)}\n`);
+ assert.ok(
+ Buffer.byteLength(data.rawStderr) <= SMALL_CAPS.stderr + Buffer.byteLength(TRUNCATION_MARKER),
+ "rawStderr bounded",
+ );
+ assert.ok(data.rawStderr.endsWith(TRUNCATION_MARKER));
+
+ // logs: several event lines whose messages exceed the 32-byte logs cap.
+ for (let i = 0; i < 5; i += 1) {
+ stderr.emit("data", `__JAIPH_EVENT__ ${JSON.stringify({ type: "LOG", message: "m".repeat(20) })}\n`);
+ }
+ const logsBytes = data.logs.reduce((n, m) => n + Buffer.byteLength(m), 0);
+ assert.ok(logsBytes <= SMALL_CAPS.logs + Buffer.byteLength(TRUNCATION_MARKER.trim()), "logs bounded");
+ assert.equal(data.logs[data.logs.length - 1], TRUNCATION_MARKER.trim(), "logs truncation marker recorded once");
+ assert.equal(data.logs.filter((m) => m === TRUNCATION_MARKER.trim()).length, 1, "marker pushed exactly once");
+ drain();
+});
diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts
index 62ace220..91e52a94 100644
--- a/src/cli/exec/call.ts
+++ b/src/cli/exec/call.ts
@@ -86,6 +86,52 @@ export interface CollectedOutput {
rawStdout: string;
}
+/**
+ * Byte caps that bound one call's in-memory capture. A long-lived server
+ * (`jaiph serve`) can be driven by an authenticated caller into producing
+ * unbounded stdout/stderr/log output; these caps stop a single run from
+ * exhausting process memory, and bound the `result_text` the server keeps
+ * resident per run. Each field is measured in UTF-8 bytes. The defaults keep an
+ * ordinary run's full output; the server lowers them via env when needed.
+ */
+export interface OutputCaps {
+ /** Max bytes retained for raw stdout capture. */
+ stdout: number;
+ /** Max bytes retained for raw stderr capture. */
+ stderr: number;
+ /** Max bytes retained across all collected `log` messages. */
+ logs: number;
+ /** Max bytes of the composed, returned `result_text`. */
+ resultText: number;
+}
+
+/**
+ * Effectively-unbounded caps — the default for every caller that does not opt
+ * in (`jaiph mcp`, direct callers), so their existing behavior is unchanged.
+ * Only the long-lived HTTP service (`jaiph serve`) passes finite caps.
+ */
+export const DEFAULT_OUTPUT_CAPS: OutputCaps = {
+ stdout: Number.MAX_SAFE_INTEGER,
+ stderr: Number.MAX_SAFE_INTEGER,
+ logs: Number.MAX_SAFE_INTEGER,
+ resultText: Number.MAX_SAFE_INTEGER,
+};
+
+/**
+ * Deterministic marker appended to any captured stream or result text cut at a
+ * cap, so a truncated response is self-describing rather than silently short.
+ */
+export const TRUNCATION_MARKER = "\n[jaiph: output truncated — exceeded the configured byte cap]";
+
+/** Cap `text` to `cap` UTF-8 bytes, appending {@link TRUNCATION_MARKER} on overflow. */
+export function capBytes(text: string, cap: number): string {
+ if (Buffer.byteLength(text) <= cap) return text;
+ // Slice on a byte boundary, then drop a trailing partial multibyte char
+ // (which `toString` decodes to U+FFFD) so the head stays valid UTF-8.
+ const head = Buffer.from(text, "utf8").subarray(0, cap).toString("utf8").replace(/\uFFFD+$/, "");
+ return head + TRUNCATION_MARKER;
+}
+
/**
* Execute one workflow call. Honors the same env-driven sandbox selection as
* `jaiph run`: when `dockerConfig.enabled`, the call runs in a per-call
@@ -105,6 +151,7 @@ export async function callWorkflow(
positionalArgs: string[],
runId: string,
ctx?: WorkflowCallContext,
+ caps: OutputCaps = DEFAULT_OUTPUT_CAPS,
): Promise {
const runtimeEnv = resolveRuntimeEnv(env.effectiveConfig, env.workspaceRoot, env.inputAbs);
runtimeEnv.JAIPH_SOURCE_ABS = env.inputAbs;
@@ -112,8 +159,8 @@ export async function callWorkflow(
runtimeEnv.JAIPH_SCRIPTS = env.scriptsDir;
const result = dockerConfig.enabled
- ? await callWorkflowDocker(env, dockerConfig, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx)
- : await callWorkflowHost(env, workflowSymbol, positionalArgs, runtimeEnv, runId, ctx);
+ ? await callWorkflowDocker(env, dockerConfig, workflowSymbol, positionalArgs, runtimeEnv, runId, caps, ctx)
+ : await callWorkflowHost(env, workflowSymbol, positionalArgs, runtimeEnv, runId, caps, ctx);
// One export per call — the shared choke point covering every MCP tool call and
// HTTP `jaiph serve` invocation. Best-effort; never changes the call result.
await exportRunTelemetry({
@@ -133,6 +180,7 @@ async function callWorkflowHost(
positionalArgs: string[],
runtimeEnv: Record,
runId: string,
+ caps: OutputCaps,
ctx?: WorkflowCallContext,
): Promise {
runtimeEnv.JAIPH_MODULE_GRAPH_FILE = env.graphFile;
@@ -148,12 +196,12 @@ async function callWorkflowHost(
env: runtimeEnv,
});
ctx?.onCancelHandle?.(() => cancelRunProcess(child));
- const collector = attachOutputCollector(child, ctx?.onStep);
+ const collector = attachOutputCollector(child, ctx?.onStep, caps);
const exit = await waitForRunExit(child);
collector.drain();
const meta = readMetaFile(metaFile);
- return composeResult(workflowSymbol, collector.data, exit, meta.runDir, undefined, runtimeEnv);
+ return composeResult(workflowSymbol, collector.data, exit, meta.runDir, undefined, runtimeEnv, caps);
}
/**
@@ -170,6 +218,7 @@ async function callWorkflowDocker(
positionalArgs: string[],
runtimeEnv: Record,
runId: string,
+ caps: OutputCaps,
ctx?: WorkflowCallContext,
): Promise {
const sandboxMode = selectMcpSandboxMode(runtimeEnv);
@@ -193,17 +242,22 @@ async function callWorkflowDocker(
stopDockerContainer(dockerResult.containerName);
cancelRunProcess(dockerResult.child);
});
- const collector = attachOutputCollector(dockerResult.child, ctx?.onStep);
+ const collector = attachOutputCollector(dockerResult.child, ctx?.onStep, caps);
return withDockerExitGuard(dockerResult, async () => {
const exit = await waitForRunExit(dockerResult.child);
collector.drain();
const discovered = discoverDockerRunDir(sandboxRunDir, runId);
// Docker keeps `--env` passthrough out of runtimeEnv (it flows through
// DockerSpawnOptions.extraEnv), so merge it back in for redaction.
- return composeResult(workflowSymbol, collector.data, exit, discovered.runDir, sandboxRunDir, {
- ...runtimeEnv,
- ...env.extraEnv,
- });
+ return composeResult(
+ workflowSymbol,
+ collector.data,
+ exit,
+ discovered.runDir,
+ sandboxRunDir,
+ { ...runtimeEnv, ...env.extraEnv },
+ caps,
+ );
});
}
@@ -214,15 +268,35 @@ async function callWorkflowDocker(
* event so the caller can stream progress. `drain()` flushes any trailing
* partial stderr line.
*/
-function attachOutputCollector(
+export function attachOutputCollector(
child: ChildProcess,
- onStep?: (kind: string, name: string) => void,
+ onStep: ((kind: string, name: string) => void) | undefined,
+ caps: OutputCaps,
): { data: CollectedOutput; drain: () => void } {
const data: CollectedOutput = { logs: [], rawStderr: "", rawStdout: "" };
+ // Per-stream byte counters + one-shot "cut" flags so accumulation stops at the
+ // cap (each stream overshoots by at most one over-cap chunk, which is then
+ // dropped) and the truncation marker is recorded exactly once.
+ let logsBytes = 0;
+ let logsCut = false;
+ let stderrBytes = 0;
+ let stderrCut = false;
+ let stdoutBytes = 0;
+ let stdoutCut = false;
+
const onStderrLine = (line: string): void => {
const logEvent = parseLogEvent(line);
if (logEvent) {
- data.logs.push(logEvent.message);
+ if (!logsCut) {
+ const b = Buffer.byteLength(logEvent.message);
+ if (logsBytes + b <= caps.logs) {
+ data.logs.push(logEvent.message);
+ logsBytes += b;
+ } else {
+ data.logs.push(TRUNCATION_MARKER.trim());
+ logsCut = true;
+ }
+ }
return;
}
const stepEvent = parseStepEvent(line);
@@ -239,7 +313,17 @@ function attachOutputCollector(
onStep?.(stepEvent.kind, stepEvent.name);
return;
}
- data.rawStderr += `${line}\n`;
+ if (!stderrCut) {
+ const add = `${line}\n`;
+ const b = Buffer.byteLength(add);
+ if (stderrBytes + b <= caps.stderr) {
+ data.rawStderr += add;
+ stderrBytes += b;
+ } else {
+ data.rawStderr += TRUNCATION_MARKER;
+ stderrCut = true;
+ }
+ }
};
let stderrBuf = "";
@@ -255,7 +339,15 @@ function attachOutputCollector(
});
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
- data.rawStdout += chunk;
+ if (stdoutCut) return;
+ const b = Buffer.byteLength(chunk);
+ if (stdoutBytes + b <= caps.stdout) {
+ data.rawStdout += chunk;
+ stdoutBytes += b;
+ } else {
+ data.rawStdout += TRUNCATION_MARKER;
+ stdoutCut = true;
+ }
});
return {
@@ -276,6 +368,11 @@ function attachOutputCollector(
* not redacted at the source, so this must not rely on the event stream.
* A successful workflow's return value is intentional API output, not
* diagnostic capture, and is returned verbatim.
+ *
+ * The composed `text` is capped at `caps.resultText` bytes (with a deterministic
+ * truncation marker) as the final backstop: it is the value a long-lived
+ * `jaiph serve` keeps resident per run, so an unbounded return value or log
+ * dump cannot grow the run registry without limit.
*/
export function composeResult(
workflowSymbol: string,
@@ -284,6 +381,7 @@ export function composeResult(
runDir: string | undefined,
sandboxRunDir: string | undefined,
env: NodeJS.ProcessEnv,
+ caps: OutputCaps = DEFAULT_OUTPUT_CAPS,
): WorkflowCallResult {
const failed = exit.status !== 0 || exit.signal !== null;
@@ -295,7 +393,13 @@ export function composeResult(
: data.logs.length > 0
? redactCredentials(data.logs.join("\n"), env)
: `workflow ${workflowSymbol} completed`;
- return { text: trimTrailingNewline(text), isError: false, runDir, exitStatus: exit.status, signal: exit.signal };
+ return {
+ text: capBytes(trimTrailingNewline(text), caps.resultText),
+ isError: false,
+ runDir,
+ exitStatus: exit.status,
+ signal: exit.signal,
+ };
}
const parts: string[] = [];
@@ -318,7 +422,7 @@ export function composeResult(
// streams, and logs — passes the same boundary regardless of which branch
// contributed it.
return {
- text: redactCredentials(parts.join("\n\n"), env),
+ text: capBytes(redactCredentials(parts.join("\n\n"), env), caps.resultText),
isError: true,
runDir,
exitStatus: exit.status,
diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts
index db105bdd..7ed5c841 100644
--- a/src/cli/serve/handler.test.ts
+++ b/src/cli/serve/handler.test.ts
@@ -41,6 +41,9 @@ function makeHandler(overrides?: {
tools?: McpToolSpec[];
token?: string;
maxConcurrent?: number;
+ retainRuns?: number;
+ retainAgeSec?: number;
+ now?: () => string;
}): ServeHandler {
let n = 0;
return new ServeHandler({
@@ -50,7 +53,9 @@ function makeHandler(overrides?: {
callTool: overrides?.callTool ?? (async () => ({ text: "done", isError: false, exitStatus: 0 })),
token: overrides?.token,
maxConcurrent: overrides?.maxConcurrent ?? 4,
- now: () => "2026-07-24T00:00:00.000Z",
+ retainRuns: overrides?.retainRuns,
+ retainAgeSec: overrides?.retainAgeSec,
+ now: overrides?.now ?? (() => "2026-07-24T00:00:00.000Z"),
newRunId: () => `run-${n++}`,
});
}
@@ -250,6 +255,91 @@ test("GET /v1/runs/{id} for an unknown id is 404, and lists newest first", async
assert.equal(list.runs[1].run_id, "run-0");
});
+// === run retention (bounded in-memory registry) ===
+
+test("count retention evicts only the oldest terminal records, keeping the newest", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], retainRuns: 2, callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }) });
+ for (let i = 0; i < 5; i += 1) {
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ }
+ // 5 completed, retain 2 → only run-3 and run-4 (newest) survive.
+ const ids = [...h.runs.keys()].sort();
+ assert.deepEqual(ids, ["run-3", "run-4"]);
+ const notFound = await h.handleRequest(req("GET", "/v1/runs/run-0"));
+ assert.equal(notFound.status, 404, "evicted run is gone from the registry");
+});
+
+test("retention never evicts an active run even when the terminal budget is exceeded", async () => {
+ let releaseActive!: (r: WorkflowCallResult) => void;
+ let calls = 0;
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ retainRuns: 1,
+ callTool: () => {
+ calls += 1;
+ // The first call stays in-flight; later calls resolve immediately.
+ if (calls === 1) return new Promise((r) => (releaseActive = r));
+ return Promise.resolve({ text: "ok", isError: false, exitStatus: 0 });
+ },
+ });
+ const active = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs")));
+ assert.equal(active.status, "running");
+ // Complete three more runs; with retainRuns=1 they churn, but the active run
+ // must never be evicted.
+ for (let i = 0; i < 3; i += 1) {
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ }
+ assert.ok(h.runs.has(active.run_id), "the running run survives eviction");
+ releaseActive({ text: "ok", isError: false, exitStatus: 0 });
+ await flush();
+});
+
+test("age retention evicts a completed run once it is older than the window", async () => {
+ let clock = "2026-07-24T00:00:00.000Z";
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ retainAgeSec: 60,
+ now: () => clock,
+ callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }),
+ });
+ // First run ends at T0.
+ const first = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true")));
+ assert.ok(h.runs.has(first.run_id));
+ // Advance the clock 2 minutes; a new completion triggers age eviction of the
+ // now-stale first run (ended > 60s ago).
+ clock = "2026-07-24T00:02:00.000Z";
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ assert.ok(!h.runs.has(first.run_id), "the stale completed run was evicted");
+});
+
+// === /v1/runs pagination (bounded listing) ===
+
+test("GET /v1/runs paginates: bounded default, stable newest-first order, total count", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }) });
+ for (let i = 0; i < 5; i += 1) {
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ }
+ const page = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=2")));
+ assert.equal(page.total, 5, "total reflects the full registry");
+ assert.equal(page.limit, 2);
+ assert.equal(page.offset, 0);
+ assert.deepEqual(page.runs.map((r: any) => r.run_id), ["run-4", "run-3"], "newest first");
+
+ const next = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=2&offset=2")));
+ assert.deepEqual(next.runs.map((r: any) => r.run_id), ["run-2", "run-1"], "stable next page");
+});
+
+test("GET /v1/runs clamps limit to the maximum and cannot return an unbounded response", async () => {
+ const h = makeHandler({ tools: [NOARG_TOOL], callTool: async () => ({ text: "ok", isError: false, exitStatus: 0 }) });
+ await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true"));
+ const page = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=999999")));
+ assert.equal(page.limit, 1000, "limit clamped to MAX_RUNS_PAGE");
+
+ // A malformed limit falls back to the bounded default rather than being unbounded.
+ const bad = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=abc")));
+ assert.equal(bad.limit, 100);
+});
+
// === cancellation ===
test("cancel: 202 then terminal cancelled, invoking child + container teardown", async () => {
diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts
index 59047180..0af0d6d9 100644
--- a/src/cli/serve/handler.ts
+++ b/src/cli/serve/handler.ts
@@ -16,6 +16,11 @@ import {
/** 1 MiB cap on request bodies (design doc). */
export const MAX_BODY_BYTES = 1024 * 1024;
+/** Default page size for `GET /v1/runs` when the caller gives no `limit`. */
+export const DEFAULT_RUNS_PAGE = 100;
+/** Hard maximum page size for `GET /v1/runs` — a `limit` above this is clamped. */
+export const MAX_RUNS_PAGE = 1000;
+
export type RunStatus = "running" | "succeeded" | "failed" | "cancelled";
/** In-memory record for one run: the public run object plus cancel bookkeeping. */
@@ -83,6 +88,20 @@ export interface ServeHandlerOptions {
token?: string;
/** Cap on simultaneously-running workflows (429 beyond it). */
maxConcurrent: number;
+ /**
+ * Max completed (terminal) runs kept in the in-memory registry. When the
+ * terminal count exceeds this, the oldest terminal records are evicted first.
+ * Active (`running`) runs are never evicted. `0` disables count eviction.
+ * Eviction only drops the in-memory record; the durable `run_summary.jsonl`
+ * and `artifacts/` on disk are untouched (their retention is the operator's).
+ */
+ retainRuns?: number;
+ /**
+ * Max age in seconds of a completed run's `ended_at` before it is evicted
+ * from the in-memory registry. `0` disables age eviction. Same disk caveat
+ * as {@link retainRuns}.
+ */
+ retainAgeSec?: number;
/** Current-time source (ISO string), injectable for tests. */
now: () => string;
/** Run-id source, injectable for tests. Defaults to `randomUUID`. */
@@ -194,10 +213,7 @@ export class ServeHandler {
if (path === "/v1/runs") {
if (method !== "GET") return this.methodNotAllowed();
- const runs = [...this.runs.values()]
- .sort((a, b) => b.order - a.order)
- .map((r) => this.toRunObject(r));
- return this.json(200, { runs });
+ return this.listRuns(req);
}
const events = /^\/v1\/runs\/([^/]+)\/events$/.exec(path);
@@ -320,6 +336,47 @@ export class ServeHandler {
};
}
+ /**
+ * `GET /v1/runs`: newest-first page of runs. `limit` defaults to
+ * {@link DEFAULT_RUNS_PAGE} and is clamped to `[1, MAX_RUNS_PAGE]`; `offset`
+ * defaults to 0 (clamped to `>= 0`). The response can never be unbounded —
+ * at most `MAX_RUNS_PAGE` records regardless of the query. Order is stable:
+ * the monotonic `order` field is unique and never reused, so paging is
+ * deterministic across requests as long as no new run is inserted between
+ * them. `total` is the full registry size so a client can page through it.
+ */
+ private listRuns(req: ServeRequest): ServeResponse {
+ const limit = clampInt(req.query.get("limit"), DEFAULT_RUNS_PAGE, 1, MAX_RUNS_PAGE);
+ const offset = clampInt(req.query.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
+ const sorted = [...this.runs.values()].sort((a, b) => b.order - a.order);
+ const runs = sorted.slice(offset, offset + limit).map((r) => this.toRunObject(r));
+ return this.json(200, { runs, total: sorted.length, limit, offset });
+ }
+
+ /**
+ * Drop terminal records past the retention limits so the registry cannot grow
+ * without bound over a long-lived server. Active runs are never touched, and
+ * only the in-memory record is removed — the durable journal and artifacts on
+ * disk are the operator's to prune. Called after every run finalizes.
+ */
+ private evictCompleted(): void {
+ const ageSec = this.opts.retainAgeSec ?? 0;
+ if (ageSec > 0) {
+ const cutoff = Date.parse(this.opts.now()) - ageSec * 1000;
+ for (const r of this.runs.values()) {
+ if (!isTerminal(r.status) || !r.ended_at) continue;
+ const ended = Date.parse(r.ended_at);
+ if (Number.isFinite(ended) && ended < cutoff) this.runs.delete(r.run_id);
+ }
+ }
+ const max = this.opts.retainRuns ?? 0;
+ if (max > 0) {
+ // Oldest terminal records first (ascending `order`); keep the newest `max`.
+ const terminal = [...this.runs.values()].filter((r) => isTerminal(r.status)).sort((a, b) => a.order - b.order);
+ for (let i = 0; i < terminal.length - max; i += 1) this.runs.delete(terminal[i].run_id);
+ }
+ }
+
private cancelRun(id: string): ServeResponse {
const record = this.runs.get(id);
if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id");
@@ -424,12 +481,14 @@ export class ServeHandler {
record.result_text = result.text;
record.run_dir = result.runDir ?? null;
record.status = record.cancelled ? "cancelled" : result.isError ? "failed" : "succeeded";
+ this.evictCompleted();
}
private finalizeError(record: RunRecord, err: unknown): void {
record.ended_at = this.opts.now();
record.result_text = err instanceof Error ? err.message : String(err);
record.status = record.cancelled ? "cancelled" : "failed";
+ this.evictCompleted();
}
private authorized(req: ServeRequest): boolean {
@@ -474,3 +533,17 @@ export class ServeHandler {
function isJsonContentType(contentType: string | undefined): boolean {
return typeof contentType === "string" && contentType.split(";")[0].trim().toLowerCase() === "application/json";
}
+
+/**
+ * Parse a query param as an integer, clamped to `[min, max]`. A missing or
+ * malformed value falls back to `fallback` (itself already within range), so a
+ * hostile `?limit=` can never widen the page beyond `max`.
+ */
+function clampInt(raw: string | null, fallback: number, min: number, max: number): number {
+ if (raw === null || raw.trim() === "") return fallback;
+ const n = Number(raw);
+ if (!Number.isInteger(n)) return fallback;
+ if (n < min) return min;
+ if (n > max) return max;
+ return n;
+}
diff --git a/src/cli/serve/openapi.ts b/src/cli/serve/openapi.ts
index eb87cc1b..d1ff0f33 100644
--- a/src/cli/serve/openapi.ts
+++ b/src/cli/serve/openapi.ts
@@ -143,17 +143,38 @@ export function buildOpenApi(tools: McpToolSpec[], serverInfo: OpenApiServerInfo
paths["/v1/runs"] = {
get: {
operationId: "listRuns",
- summary: "List runs started by this server (newest first)",
+ summary: "List runs started by this server (newest first, paginated)",
security: BEARER_SECURITY,
+ parameters: [
+ {
+ name: "limit",
+ in: "query",
+ required: false,
+ description: "Page size (default 100, clamped to 1000). The response is never unbounded.",
+ schema: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
+ },
+ {
+ name: "offset",
+ in: "query",
+ required: false,
+ description: "Number of newest-first records to skip before the page.",
+ schema: { type: "integer", minimum: 0, default: 0 },
+ },
+ ],
responses: {
"200": {
- description: "Runs started by this server process.",
+ description: "One page of runs started by this server process, plus the total registry size.",
content: {
"application/json": {
schema: {
type: "object",
- properties: { runs: { type: "array", items: { $ref: "#/components/schemas/Run" } } },
- required: ["runs"],
+ properties: {
+ runs: { type: "array", items: { $ref: "#/components/schemas/Run" } },
+ total: { type: "integer" },
+ limit: { type: "integer" },
+ offset: { type: "integer" },
+ },
+ required: ["runs", "total", "limit", "offset"],
},
},
},
From de9f10160e29ef46ece3171fc1e03ffdb2be7bb4 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 15:38:28 +0200
Subject: [PATCH 11/24] Chore: lower Claude capacity threshold
Pause engineer queue processing once current-session usage exceeds 90 percent.
Co-authored-by: Cursor
---
.jaiph/engineer.jh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index 95af445c..2f991cdc 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -214,8 +214,8 @@ script check_claude_capacity = ```
;;
esac
- printf 'Claude current session usage is %s%%.\n' "$percent"
- (( percent <= 95 ))
+ printf 'Claude current session usage is %s%%.' "$percent"
+ (( percent <= 90 ))
```
script first_line_task = ```
From ba416d08ac16bdbf145099678d2e6b432066132a Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 15:38:29 +0200
Subject: [PATCH 12/24] Fix: harden deployment and stream serve I/O
Make the Kubernetes example operationally safe and keep HTTP request, event, and artifact handling bounded by bytes transferred.
Co-authored-by: Cursor
---
.github/workflows/ci.yml | 18 +++
CHANGELOG.md | 4 +
QUEUE.md | 36 ------
docs/cli.md | 7 +-
docs/deploy.md | 14 +-
docs/deploy/k8s.yaml | 108 ++++++++++++----
docs/env-vars.md | 1 +
docs/serve.md | 2 +
e2e/test_all.sh | 1 +
e2e/tests/150_k8s_deploy.sh | 211 +++++++++++++++++++++++++++++++
integration/serve-server.test.ts | 67 +++++++++-
src/cli/commands/serve.ts | 13 +-
src/cli/serve/handler.test.ts | 113 ++++++++++++++++-
src/cli/serve/handler.ts | 96 ++++++++++----
src/cli/serve/runfiles.test.ts | 98 +++++++++++++-
src/cli/serve/runfiles.ts | 126 ++++++++++++------
src/cli/serve/server.test.ts | 187 +++++++++++++++++++++++++++
src/cli/serve/server.ts | 68 +++++++++-
18 files changed, 1029 insertions(+), 141 deletions(-)
create mode 100755 e2e/tests/150_k8s_deploy.sh
create mode 100644 src/cli/serve/server.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 615110da..35bad025 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,12 +41,30 @@ jobs:
# `kubectl apply --dry-run=client` still needs an API server for resource
# discovery (RESTMapper), so provision a throwaway kind cluster for it.
+ # The same cluster then backs the real deploy test below.
- name: Create kind cluster
uses: helm/kind-action@v1
+ with:
+ cluster_name: jaiph-e2e
+ # Fast schema gate — cheap, but only proves the manifest parses.
- name: Dry-run apply the standalone deploy manifest
run: kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+ - name: Build runtime image for the deploy test
+ run: docker build -t jaiph-e2e-runtime:local -f runtime/Dockerfile .
+
+ # Real deployment contract: external Secret gate, pod hardening
+ # (non-root, no privilege escalation, dropped caps, no SA token,
+ # read-only rootfs), an authenticated HTTP run, and its journal on the
+ # writable runs volume.
+ - name: Deploy and exercise the manifest on kind
+ run: |
+ JAIPH_E2E_SKIP_INSTALL=1 \
+ JAIPH_E2E_KIND_CLUSTER=jaiph-e2e \
+ JAIPH_E2E_DOCKER_IMAGE=jaiph-e2e-runtime:local \
+ bash e2e/tests/150_k8s_deploy.sh
+
e2e:
name: E2E (${{ matrix.os }}, ${{ matrix.label }})
runs-on: ${{ matrix.os }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53cc06a5..402a32f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,8 +12,12 @@
## All changes
+- **Fix — make `jaiph serve` request, event, and artifact I/O scale with bytes transferred:** the HTTP layer still performed avoidable whole-resource work on four paths, all now proportional to the bytes actually moved. **Request bodies settle on abort:** `readBody` (`src/cli/serve/server.ts`) only settled on `end`/`error`, so a client destroyed mid-upload left the promise pending forever with its buffered chunks pinned; it now settles on premature `close`/`error` with `aborted: true`, the connection handler drops the request without invoking the router, and no run slot is ever occupied (the helper is exported for direct unit testing of the settlement contract). **Run-dir resolution is cached:** the events/artifacts endpoints located a still-running run by scanning the whole `.jaiph/runs` tree (`findRunDir`) on *every* SSE poll until finalize set `run_dir`; `ServeHandler.runDirFor` now caches the first resolver hit on the record (`RunRecord.resolvedRunDir`, never part of the public run object, evicted with the record), so one live SSE connection scans at most once no matter how long it follows. **Journals follow by fd + offset:** the SSE follower reread the entire `run_summary.jsonl` from disk on every 250 ms poll; the new `createJournalFollower` (`src/cli/serve/runfiles.ts`) holds an open file descriptor and a byte offset, reads only appended bytes with positional `readSync`, buffers a partial trailing line in memory instead of rereading it, and is closed in the stream's `finally` so a disconnect releases the fd. **Artifacts (and NDJSON journals) stream:** `GET /v1/runs/{id}/artifacts/{path}` loaded the complete file into memory (`readFileSync`) before responding; the handler now returns a `bodyFile` `{path, size}` that the HTTP layer pipes through `node:stream.pipeline` — backpressure pauses the read when the socket is congested, a client disconnect destroys the file stream, `content-length` is set from the stat and the read is pinned to it (`end: size - 1`) so an appending journal can't overrun the advertised length, and a multi-gigabyte artifact costs no server memory. The NDJSON `GET /v1/runs/{id}/events` path streams the journal the same way. An explicit size policy joins the streaming: `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap, since streaming already bounds memory) refuses larger artifacts with `413 E_ARTIFACT_TOO_LARGE`. Tests (each verified to fail against the pre-fix behavior): `src/cli/serve/server.test.ts` (readBody settles promptly on premature close; a destroyed mid-upload request occupies no run slot, never starts a workflow, and the server keeps serving; a 1 MiB artifact round-trips byte-identically through a real socket with `content-length`; a stalled 256 MiB download's file stream is destroyed when the client disconnects), `src/cli/serve/handler.test.ts` (a live SSE connection resolves the run dir with exactly one scan across many polls; the artifact cap returns 413 past the limit while smaller files stream; NDJSON/artifact responses carry `bodyFile` + `content-length`), `src/cli/serve/runfiles.test.ts` (an instrumented-`fs` SSE follow proves each appended journal byte is read exactly once and never before the current offset, and that no poll loads the journal whole; the follower completes a split line from its buffered tail), and `integration/serve-server.test.ts` (a 3 GiB sparse artifact streams to a real client with the serve process's RSS sampled below 1.5 GiB throughout — a buffering server would hold 3 GiB+). Docs: a streaming-download + `JAIPH_SERVE_MAX_ARTIFACT_BYTES` note in the artifact-download step of [Serve workflows over HTTP](docs/serve.md), the `JAIPH_SERVE_MAX_ARTIFACT_BYTES` row in [Environment variables](docs/env-vars.md), the artifact/events endpoint rows plus the `413 E_ARTIFACT_TOO_LARGE` error code and serve-limits bullet in [CLI](docs/cli.md#jaiph-serve), and the `jaiph serve` usage text.
+
- **Fix — bound `jaiph serve` memory: per-run output caps, completed-run retention, and paginated listing:** `jaiph serve`'s concurrency cap limits *active* children but not process memory, so over a long-lived server an authenticated caller could exhaust it three ways — the in-memory run map grew forever, each completed run's `result_text` stayed resident forever, and a single run's raw stdout/stderr/`log` capture accumulated unbounded. All three are now bounded. **Per-run output caps:** a new `OutputCaps` (`src/cli/exec/call.ts`) threads a UTF-8 byte cap through the call layer; `attachOutputCollector` keeps per-stream byte counters and one-shot "cut" flags so stdout, stderr, and collected `log` output each stop accumulating at the cap, and `composeResult` caps the composed `result_text` as a final backstop. Overflow is dropped and replaced with a deterministic `TRUNCATION_MARKER` (`[jaiph: output truncated — exceeded the configured byte cap]`); the new `capBytes` helper slices on a byte boundary and drops a trailing partial multibyte char so the head stays valid UTF-8. `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) sets one value applied **independently** to each of the four channels. The default caps are effectively unbounded (`Number.MAX_SAFE_INTEGER` via `DEFAULT_OUTPUT_CAPS`), so `jaiph mcp` and direct callers are byte-for-byte unchanged — only `jaiph serve` passes finite caps. **Completed-run retention:** `ServeHandler.evictCompleted` (`src/cli/serve/handler.ts`), called after every finalize, drops terminal records past `JAIPH_SERVE_RETAIN_RUNS` (default `500`, oldest terminal `order` evicted first) and older than `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`; `0` disables age eviction). **Active (`running`) runs are never evicted**, and eviction removes only the in-memory record — the durable `run_summary.jsonl` journal and `artifacts/` on disk are untouched and remain the operator's to prune. **Bounded listing:** `GET /v1/runs` is now paginated via `?limit` (default `100`, clamped to `1000` by `clampInt`) and `?offset`, returning `{runs, total, limit, offset}`; a hostile `?limit=` can never widen the page past the maximum, so the response is never unbounded, and the monotonic unique `order` field keeps newest-first paging stable. The OpenAPI document (`src/cli/serve/openapi.ts`) gains the `limit`/`offset` parameters and the `total`/`limit`/`offset` response fields. `jaiph serve` startup parses the three env vars with a shared `intEnv` validator (a non-integer or out-of-range value prints a diagnostic to stderr and exits `1`) and logs the effective memory bounds plus the durable-artifact operator caveat. Tests: `src/cli/exec/call.test.ts` (`capBytes` verbatim vs. marked overflow and no U+FFFD split; `composeResult` caps a runaway success return and a runaway failure narrative; `attachOutputCollector` bounds stdout/stderr/logs each with a marker), `src/cli/serve/handler.test.ts` (count retention evicts only the oldest terminal records; an active run survives even past the budget; age retention evicts once past the window; pagination is bounded, newest-first stable, and reports `total`; `limit` is clamped to the maximum), and `e2e/tests/147_serve_http_api.sh` (`?limit=1` returns exactly one record while echoing the limit and full total). Concurrency, cancellation, SSE, and artifact tests continue to pass. Docs: a new [Bound memory over a long-lived server](docs/serve.md#8-bound-memory-over-a-long-lived-server) section in [Serve workflows over HTTP](docs/serve.md), the three `JAIPH_SERVE_*` rows in [Environment variables](docs/env-vars.md), and the paginated `GET /v1/runs` row plus a memory-bounds bullet in the `jaiph serve` section of [CLI](docs/cli.md#jaiph-serve).
+- **Fix — make the Kubernetes example runnable and hardened by default:** `docs/deploy/k8s.yaml` used to mount the workflow ConfigMap read-only at `/work` while standalone host mode writes runs under `/.jaiph/runs` — schema-valid, but a real run could never land its journal — and it shipped applyable placeholder credentials (`JAIPH_SERVE_TOKEN: "replace-me-…"`) plus none of the pod hardening `docs/deploy.md` says the operator must supply. The manifest now sets `JAIPH_RUNS_DIR=/jaiph/runs` onto a dedicated writable `emptyDir` (swap for a PVC for durability) while `/work` stays read-only; the `Secret` manifest is **gone** — the Deployment references an external `jaiph-credentials` Secret as a required `envFrom`, so a missing Secret holds the pod in `CreateContainerConfigError` instead of ever starting unauthenticated, and the header documents the `kubectl create secret generic` contract. Hardening is on by default: `runAsNonRoot` with the image's fixed UID/GID `10001`, `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, `seccompProfile: RuntimeDefault`, `automountServiceAccountToken: false`, and `readOnlyRootFilesystem: true` with writable `emptyDir`s only where Jaiph and the agent CLIs genuinely write (`/tmp`, and a fresh `$HOME` at `/jaiph/home` — the baked `PATH` still resolves `cursor-agent` from the read-only image home). Commented env entries show credential-free OTLP / Sentry wiring (secrets go in the Secret, addresses in the manifest). Tests: new `e2e/tests/150_k8s_deploy.sh` (gated on docker+kind+kubectl; run in CI's `k8s-manifest` job against a kind cluster) applies the manifest with the locally built image, asserts the missing-Secret gate and every hardening field (spec fields **and** effective behavior: `id -u` is `10001`, no SA token file, root FS and `/work` non-writable), invokes the `health` workflow over HTTP with bearer auth (and asserts `401` without it), asserts the run's `run_dir` is on the runs volume, and byte-compares `GET /v1/runs/{id}/events` against `run_summary.jsonl` read from that volume — `kubectl apply --dry-run=client` remains as the fast schema gate but is no longer the only deployment test. Docs: the Kubernetes section of [Deploy the runtime image standalone](docs/deploy.md) now covers the external Secret contract, the hardening set, and the writable-mount map.
+
- **Fix — keep `jaiph mcp` generations alive while calls are in flight, and drain before shutdown:** `jaiph mcp` handles tool calls concurrently, but a hot reload used to delete the previous generation's out dir (emitted scripts + serialized graph) the instant it swapped — `rmSync(previousOutDir, …)` ran synchronously in the reload handler — so a call that started just before the reload lost the scripts its remaining steps spawn from, and signal shutdown removed the shared temp root without draining or cancelling active calls. `jaiph serve` already refcounted its generations (HTTP runs can outlive a reload); MCP now gets the same guarantee from a **shared** lifecycle. The `LiveGeneration` refcount model was extracted from `src/cli/commands/serve.ts` into `createGenerationTracker` in `src/cli/shared/generation.ts` and is now used by both commands: each call `acquire()`s a **lease** on the generation live at call start (`lease.state` is stable for the call's lifetime), and a superseded generation's out dir is deleted only when its **last lease releases** — immediately on swap when idle, otherwise when the final in-flight call settles. A call spanning one or more reloads therefore always runs its remaining script steps from the generation it captured, and no generation directory is left behind after all calls settle. **Shutdown is now drain-then-cancel** (matching `jaiph serve`): stdin closing or the **first** `SIGINT`/`SIGTERM` stops accepting input and awaits every in-flight call before removing the temp root and exiting `0` — a draining call keeps its scripts on disk until it settles. A **second** signal cancels instead of waiting: the new `McpServer.cancelAll()` (`src/cli/mcp/server.ts`) invokes each in-flight call's cancel handle, terminating its run's child process tree (`SIGINT`, then `SIGKILL` after a grace period) and, in Docker mode, force-removing its container (`docker rm -f`), so no child process or container is orphaned; unlike a client `notifications/cancelled`, the calls are **not** marked cancelled — each killed run settles with a normal `isError` response, so the drain awaiting them completes deterministically and the server still exits `0`. Tests: new `src/cli/shared/generation.test.ts` (lease pins the generation across a swap; last release deletes the superseded dir; concurrent leases across multiple swaps settle out-of-order and leave only the current dir; release is idempotent and cannot free a dir another lease still holds), `src/cli/mcp/server.test.ts` (`cancelAll` kills every in-flight run yet still delivers each `isError` response), and a new `e2e/tests/149_mcp_generation_lifecycle.sh` (wired into `e2e/test_all.sh`): a slow call started before a reload returns the **old** generation's value while a post-reload call returns the **new** one and stdin-close drains both; SIGTERM drains an in-flight call to completion with no orphaned script process; a second SIGTERM cancels with an `isError` result and no orphan. Docs: updated `jaiph mcp` shutdown/exit behavior and a generation-lease note in [CLI](docs/cli.md#jaiph-mcp), an in-flight-call note under hot reload and a new "Shutdown (drain, then cancel)" section in [Serve workflows as MCP tools](docs/mcp.md).
- **Fix — redact credentials from every returned workflow result, not just the durable journal:** `jaiph serve`'s `result_text` and a `jaiph mcp` tool error could return a secret that events, OTLP, and Sentry all redacted. The failure text was built in `composeResult` (`src/cli/exec/call.ts`) from the run's **live** captured output — failed-step detail, raw stderr/stdout, and collected `log` messages — whereas only the durable `run_summary.jsonl` copies were credential-redacted by `RuntimeEventEmitter`; a workflow that echoed a `*_API_KEY` / `*_TOKEN` / `*_SECRET` value to a stream and then failed leaked it verbatim through `?wait=true`, `GET /v1/runs/{id}`, and MCP tool results. The redaction rule is now **one shared helper**, `redactCredentials` (extracted from `RuntimeEventEmitter` into the new `src/runtime/kernel/redact.ts` — same credential-key suffixes, same ≥8-char threshold), imported by both the journal writer and `composeResult`. `composeResult` redacts the fully assembled failure text once, so step detail, raw streams, and logs all pass the same boundary regardless of which branch contributed them, and it does not rely on the (unredacted-at-source) live `__JAIPH_EVENT__` stream. This is the single boundary both `jaiph serve` (`result_text`) and `jaiph mcp` (tool results) return through; on the Docker path the `--env` passthrough (which flows through `DockerSpawnOptions.extraEnv`, not `runtimeEnv`) is merged back in so those values redact too. **Successful return values are unchanged:** a workflow's `return` is intentional API output, not diagnostic capture, and is returned verbatim (its `log`-output fallback, being diagnostic capture, is redacted). Tests: `src/cli/exec/call.test.ts`, and integration coverage that a failing workflow echoing a credential to both streams never exposes the value through `?wait=true`, `GET /v1/runs/{id}` (`integration/serve-server.test.ts`), or an MCP tool result (`integration/mcp-server.test.ts`) while `[REDACTED]` and the failed-step diagnostics are retained; the existing event-journal, OTLP, and Sentry redaction tests still pass. Docs: [Serve workflows over HTTP](docs/serve.md), [Call a tool and read the result](docs/mcp.md), and the redaction section of [Architecture](docs/architecture.md).
diff --git a/QUEUE.md b/QUEUE.md
index f70763ed..074dc18f 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,42 +14,6 @@ Process rules:
***
-## Make the Kubernetes example runnable and hardened by default #dev-ready
-
-The current manifest mounts the workflow ConfigMap read-only at `/work`, while standalone host mode writes runs under `/work/.jaiph/runs`; the example is schema-valid but cannot complete a real run. It also ships publicly known placeholder secrets and omits the pod hardening that `docs/deploy.md` says the operator must supply.
-
-Scope:
-
-- Mount a writable `emptyDir` or PVC for `JAIPH_RUNS_DIR` without making workflow sources writable.
-- Remove applyable placeholder credentials from the base manifest; document and validate an external Secret contract.
-- Add `runAsNonRoot`, fixed UID/GID, `allowPrivilegeEscalation: false`, dropped capabilities, a runtime-default seccomp profile, and disabled service-account token mounting.
-- Provide writable mounts only for paths genuinely required by Jaiph and agent CLIs; keep the remaining filesystem read-only where feasible.
-- Add optional OTLP and Sentry env wiring examples without embedding credentials.
-
-Acceptance:
-
-- A Kind-based test applies the manifest, invokes the HTTP workflow with auth, observes a successful run, and reads its journal from the writable runs volume.
-- The pod runs as non-root and the test fails if privilege escalation, capabilities, or the default service-account token are reintroduced.
-- `kubectl apply --dry-run=client` remains a fast schema check, but is not the only deployment test.
-
-## Make HTTP request, event, and artifact I/O scale with bytes transferred #dev-ready
-
-The HTTP layer still performs avoidable whole-resource work: an aborted request body can leave `readBody` pending, SSE repeatedly scans historical run directories until `run_dir` is known, each SSE poll rereads the whole journal, and artifact downloads load the complete file into memory.
-
-Scope:
-
-- Settle request-body reads on `aborted`/premature `close` and stop all associated work.
-- Cache `run_id -> run_dir` as soon as it is first resolved so a live SSE stream does not repeatedly scan the runs tree.
-- Follow journals with an open file descriptor and byte offset instead of rereading prior bytes on every poll.
-- Stream artifacts with backpressure and an explicit configurable size policy; do not buffer the complete artifact.
-
-Acceptance:
-
-- Destroying a request mid-upload settles its handler promptly and leaves no request or run slot occupied.
-- With many historical runs, one live SSE connection performs at most one full run-directory resolution scan.
-- Instrumented SSE tests prove bytes before the current offset are not reread.
-- A multi-gigabyte sparse artifact can be served with bounded process memory, and disconnecting the client closes the file stream.
-
## Use one execution-policy contract across run, serve, and MCP #dev-ready
The shared parser accepts flags for every command, but commands silently ignore options they do not destructure. For example, `jaiph serve --unsafe` and `jaiph mcp --inplace` parse successfully but do not apply those flags. `run` exposes sandbox flags while long-lived modes require env vars, creating different mental models for the same execution engine.
diff --git a/docs/cli.md b/docs/cli.md
index 606c8ed7..148a2255 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -378,17 +378,18 @@ Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to s
| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. |
| `GET /v1/runs` | bearer | Runs started by this process (in-memory), newest first. Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. |
| `GET /v1/runs/{id}` | bearer | The run object. `404` unknown. |
-| `GET /v1/runs/{id}/events` | bearer | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot; `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
+| `GET /v1/runs/{id}/events` | bearer | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot, streamed from disk (never buffered whole); `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
| `GET /v1/runs/{id}/artifacts` | bearer | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. |
-| `GET /v1/runs/{id}/artifacts/{path}` | bearer | Download one published file (`application/octet-stream`). Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. |
+| `GET /v1/runs/{id}/artifacts/{path}` | bearer | Download one published file (`application/octet-stream`), streamed with backpressure — never buffered whole, so an arbitrarily large file costs no server memory and a client disconnect closes the file. Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. `413 E_ARTIFACT_TOO_LARGE` when the file exceeds `JAIPH_SERVE_MAX_ARTIFACT_BYTES`. |
| `POST /v1/runs/{id}/cancel` | bearer | `202`; the run reaches `cancelled`. `409` if already terminal. |
-The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled`. **A workflow failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB body cap), `415` (non-`application/json` body), and `429 E_TOO_MANY_RUNS`.
+The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled`. **A workflow failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED`, `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `413 E_BODY_TOO_LARGE` (1 MiB request-body cap), `413 E_ARTIFACT_TOO_LARGE` (artifact download over `JAIPH_SERVE_MAX_ARTIFACT_BYTES`), `415` (non-`application/json` body), and `429 E_TOO_MANY_RUNS`.
### Auth and limits
- `JAIPH_SERVE_TOKEN` (env only) enables a bearer token required on every `/v1/*` request, compared in constant time. `/healthz`, `/openapi.json`, and `/docs` stay unauthenticated (schema metadata only). On loopback the token is optional; binding a non-loopback `--host` without it is a startup error.
- `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs; requests beyond it get `429`.
+- `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap) refuses artifact downloads larger than the limit with `413`. Downloads stream with backpressure regardless, so the default keeps server memory bounded no matter the file size; set a finite cap only to reject oversized downloads outright.
- Memory bounds keep a long-lived server from growing without limit: `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) caps collected stdout, stderr, log output, and the resident `result_text` per run (overflow dropped with a truncation marker); `JAIPH_SERVE_RETAIN_RUNS` (default `500`) and `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`, `0` disables) bound how many completed runs stay in the in-memory registry, evicting the oldest terminal records first. **Active runs are never evicted**, and eviction drops only the in-memory record — durable `.jaiph/runs` journals and artifacts persist on disk and are the operator's to prune. See [Serve workflows over HTTP](serve.md#8-bound-memory-over-a-long-lived-server).
- Execution and hot reload are identical to `jaiph mcp`; a superseded generation's scripts dir survives until its in-flight HTTP runs finish.
diff --git a/docs/deploy.md b/docs/deploy.md
index 09a3861d..07c8105f 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -61,20 +61,28 @@ Reach for this image when you want the whole toolchain and all three backends pr
## Kubernetes
-A complete, apply-ready manifest lives at [`docs/deploy/k8s.yaml`](https://github.com/jaiphlang/jaiph/blob/main/docs/deploy/k8s.yaml). It defines a **Deployment**, a **Secret** for backend credentials (and the serve token), and a **Service**. Validate it without a cluster:
+A complete, apply-ready manifest lives at [`docs/deploy/k8s.yaml`](https://github.com/jaiphlang/jaiph/blob/main/docs/deploy/k8s.yaml). It defines a **Deployment** and a **Service** — credentials are deliberately **not** in the file. Create the `jaiph-credentials` Secret out-of-band first:
```bash
-kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+kubectl create secret generic jaiph-credentials \
+ --from-literal=JAIPH_SERVE_TOKEN="$(openssl rand -hex 32)" \
+ --from-literal=ANTHROPIC_API_KEY="sk-ant-..." # only the backend key(s) your workflows use
+kubectl apply -f docs/deploy/k8s.yaml
```
+The Deployment references the Secret as a **required** `envFrom` — a missing Secret holds the pod in `CreateContainerConfigError` rather than ever starting an unauthenticated runner. `kubectl apply --dry-run=client -f docs/deploy/k8s.yaml` remains a fast schema check, but the real deployment contract is exercised end-to-end on a [kind](https://kind.sigs.k8s.io/) cluster by `e2e/tests/150_k8s_deploy.sh` (run in CI): it applies the manifest, verifies the Secret gate and the hardening below, invokes the health workflow over HTTP with bearer auth, and reads the run's journal back from the runs volume.
+
The manifest runs `jaiph serve --host 0.0.0.0` as a long-lived HTTP runner (see [Serve workflows over HTTP](serve.md)) with `JAIPH_SERVE_TOKEN` sourced from the Secret, and liveness/readiness probes on `GET /healthz` (which stays open — no bearer token required). Highlights, all reflected in the file:
+- **Pod hardening by default.** `runAsNonRoot` with the image's fixed `jaiph` UID/GID (`10001`), `allowPrivilegeEscalation: false`, all capabilities dropped, the `RuntimeDefault` seccomp profile, `readOnlyRootFilesystem: true`, and `automountServiceAccountToken: false` (workflows never talk to the Kubernetes API, so they get no API credential to leak).
+- **Writable mounts only where required.** Workflow sources stay read-only (a ConfigMap at `/work`); run artifacts go to a dedicated `emptyDir` at `/jaiph/runs` via `JAIPH_RUNS_DIR` (swap it for a PVC if runs must survive pod replacement). Two more `emptyDir`s cover what Jaiph and the agent CLIs genuinely write: `/tmp` (extracted scripts, scratch) and a fresh `$HOME` at `/jaiph/home` (claude/cursor state; the baked `PATH` still finds `cursor-agent` under the image's read-only `/home/jaiph/.local/bin`).
- **Image tag pinning.** The manifest ships `:nightly` with an inline note to pin a released tag or a `@sha256:` digest for production — never track a moving tag.
- **TLS via ingress.** `jaiph serve` speaks plain HTTP; the Service stays `ClusterIP` and you terminate TLS at an Ingress / gateway (cert-manager, a cloud LB, or a mesh) in front of it. Do not expose the token-guarded API to the internet without TLS.
- **Resource requests.** Agent workloads are CPU- and memory-hungry — they spawn backend CLIs plus build/test toolchains. The manifest requests `1` CPU / `2Gi` and limits `2` CPU / `4Gi` as a starting point; tune to your workflows.
- **Auth.** Binding `0.0.0.0` without `JAIPH_SERVE_TOKEN` is a startup error by design, so the Secret is mandatory. Every `/v1/*` request then requires `Authorization: Bearer `.
+- **Observability wiring, credential-free.** Commented `env` entries show where `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_SERVICE_NAME` / `SENTRY_ENVIRONMENT` go; anything secret (`SENTRY_DSN`, an `OTEL_EXPORTER_OTLP_HEADERS` auth token) belongs in the `jaiph-credentials` Secret, never in the manifest. See [Observability](observability.md).
-The same security posture applies: the pod runs in host mode (`JAIPH_UNSAFE=true` is baked), so **isolation is the pod boundary you configure** — there is no jaiph-managed sandbox inside.
+The same security posture applies: the pod runs in host mode (`JAIPH_UNSAFE=true` is baked), so **isolation is the pod boundary** — the manifest configures that boundary, and there is no jaiph-managed sandbox inside.
## Related
diff --git a/docs/deploy/k8s.yaml b/docs/deploy/k8s.yaml
index 1e024a1e..782dfd4f 100644
--- a/docs/deploy/k8s.yaml
+++ b/docs/deploy/k8s.yaml
@@ -6,31 +6,34 @@
# host mode — the pod boundary IS the sandbox (there is no jaiph-managed Docker
# sandbox in standalone mode). See docs/deploy.md for the security posture.
#
-# Validate without a cluster:
+# Credentials are NOT part of this manifest. Create the `jaiph-credentials`
+# Secret out-of-band before applying — the Deployment references it as a
+# required secretRef, so the pod stays in CreateContainerConfigError until
+# the Secret exists:
+#
+# kubectl create secret generic jaiph-credentials \
+# --from-literal=JAIPH_SERVE_TOKEN="$(openssl rand -hex 32)" \
+# --from-literal=ANTHROPIC_API_KEY="sk-ant-..."
+#
+# JAIPH_SERVE_TOKEN is mandatory: `jaiph serve` refuses to bind a non-loopback
+# host (0.0.0.0) without it, and every /v1/* request then requires
+# `Authorization: Bearer `. Backend credentials — add only the one(s)
+# your workflows use:
+# claude -> ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)
+# cursor -> CURSOR_API_KEY
+# codex -> OPENAI_API_KEY
+#
+# Validate the schema without a cluster:
# kubectl apply --dry-run=client -f docs/deploy/k8s.yaml
+# The real deployment contract (a run completes and lands its journal on the
+# writable runs volume, the pod is non-root with no privilege escalation) is
+# exercised end-to-end against a kind cluster by e2e/tests/150_k8s_deploy.sh.
#
-# The workflow file is supplied here via a ConfigMap mounted at /work for a
-# self-contained example; a real deployment usually bakes workflows into a
-# derived image or mounts them from a volume / git-sync sidecar instead.
----
-apiVersion: v1
-kind: Secret
-metadata:
- name: jaiph-credentials
- labels:
- app: jaiph-runner
-type: Opaque
-stringData:
- # HTTP API bearer token. `jaiph serve` refuses to bind a non-loopback host
- # (0.0.0.0) unless this is set — every /v1/* request then requires
- # `Authorization: Bearer `. Replace with a real secret (e.g. via a
- # sealed-secret / external-secrets operator) before applying to a cluster.
- JAIPH_SERVE_TOKEN: "replace-me-with-a-long-random-token"
- # Backend credentials — set only the one(s) your workflows use.
- # claude -> ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)
- # cursor -> CURSOR_API_KEY
- # codex -> OPENAI_API_KEY
- ANTHROPIC_API_KEY: "replace-me"
+# The workflow file is supplied here via a ConfigMap mounted read-only at
+# /work for a self-contained example; a real deployment usually bakes
+# workflows into a derived image or mounts them from a volume / git-sync
+# sidecar instead. Workflow sources stay read-only either way — run artifacts
+# go to the dedicated runs volume via JAIPH_RUNS_DIR.
---
apiVersion: v1
kind: ConfigMap
@@ -61,6 +64,18 @@ spec:
labels:
app: jaiph-runner
spec:
+ # The runner never talks to the Kubernetes API; don't hand workflows
+ # (and the agent backends they spawn) an API credential.
+ automountServiceAccountToken: false
+ securityContext:
+ # UID/GID 10001 is the image's `jaiph` user; fsGroup makes the
+ # emptyDir volumes below group-writable for it.
+ runAsNonRoot: true
+ runAsUser: 10001
+ runAsGroup: 10001
+ fsGroup: 10001
+ seccompProfile:
+ type: RuntimeDefault
containers:
- name: jaiph
# Pin the image tag (or, better, a @sha256 digest) for reproducible
@@ -68,15 +83,54 @@ spec:
image: ghcr.io/jaiphlang/jaiph-runtime:nightly
# The image sets no ENTRYPOINT, so spell the full command.
command: ["jaiph", "serve", "--host", "0.0.0.0", "/work/tools.jh"]
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop: ["ALL"]
ports:
- name: http
containerPort: 5247
+ env:
+ # Run artifacts (journal, step captures, inbox) go to the writable
+ # runs volume — /work is the read-only ConfigMap.
+ - name: JAIPH_RUNS_DIR
+ value: /jaiph/runs
+ # Agent CLIs (claude, cursor-agent) persist state under $HOME.
+ # Point HOME at a writable emptyDir instead of the image's
+ # read-only /home/jaiph; the baked PATH still resolves
+ # cursor-agent from /home/jaiph/.local/bin.
+ - name: HOME
+ value: /jaiph/home
+ # Optional observability — uncomment and point at your stack.
+ # Values here are addresses only; anything secret (an OTLP auth
+ # header, the Sentry DSN) belongs in the jaiph-credentials Secret
+ # so it arrives via the envFrom below, never in this manifest:
+ # OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer "
+ # SENTRY_DSN: "https://@/"
+ # - name: OTEL_EXPORTER_OTLP_ENDPOINT
+ # value: "http://otel-collector.observability.svc:4318"
+ # - name: OTEL_SERVICE_NAME
+ # value: "jaiph-runner"
+ # - name: SENTRY_ENVIRONMENT
+ # value: "prod"
envFrom:
- secretRef:
+ # Required (not `optional: true`): a missing Secret must block
+ # startup rather than expose an unauthenticated runner.
name: jaiph-credentials
volumeMounts:
- name: workflows
mountPath: /work
+ readOnly: true
+ - name: runs
+ mountPath: /jaiph/runs
+ - name: home
+ mountPath: /jaiph/home
+ # `jaiph serve` extracts compiled scripts to a temp dir; agent
+ # CLIs and workflow scripts also expect a writable /tmp.
+ - name: tmp
+ mountPath: /tmp
# /healthz stays open (no bearer token required) and is served as soon
# as the HTTP listener is up.
readinessProbe:
@@ -104,6 +158,14 @@ spec:
- name: workflows
configMap:
name: jaiph-workflows
+ # Runs are durable per-run records; swap the emptyDir for a
+ # PersistentVolumeClaim if they must survive pod replacement.
+ - name: runs
+ emptyDir: {}
+ - name: home
+ emptyDir: {}
+ - name: tmp
+ emptyDir: {}
---
apiVersion: v1
kind: Service
diff --git a/docs/env-vars.md b/docs/env-vars.md
index d12af738..fbb92d6a 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -78,6 +78,7 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_RUNS_DIR` | host, runtime | path | `.jaiph/runs` under the workspace | `run.logs_dir` | Root directory for run logs. Inside Docker the host CLI overrides this to `/jaiph/run`. |
| `JAIPH_RUNS_DIR_LOCKED` | internal | bool | — | — | Lock flag for `JAIPH_RUNS_DIR`. |
| `JAIPH_SCRIPTS` | internal | path | — | — | Directory of emitted `script` files for this run. Set after `buildScripts()`. Any parent-shell value is cleared before launch. |
+| `JAIPH_SERVE_MAX_ARTIFACT_BYTES` | host | int | `0` (no cap) | — | `jaiph serve` — max size (bytes) of one artifact download; a larger file is refused with HTTP `413`. Downloads always stream with backpressure, so `0` (no cap) still keeps server memory bounded regardless of artifact size. Must be `>= 0`. |
| `JAIPH_SERVE_MAX_CONCURRENT` | host | int | `4` | — | `jaiph serve` — cap on simultaneously-running workflows; requests beyond it get HTTP `429`. Must be a positive integer. |
| `JAIPH_SERVE_MAX_OUTPUT_BYTES` | host | int | `1048576` (1 MiB) | — | `jaiph serve` — per-run byte cap applied independently to collected stdout, stderr, log output, and the public `result_text`. Output beyond the cap is dropped with a deterministic truncation marker, bounding one run's memory. Must be a positive integer. |
| `JAIPH_SERVE_RETAIN_AGE_SEC` | host | int | `86400` (24h) | — | `jaiph serve` — max age (seconds, from `ended_at`) of a completed run kept in the in-memory registry; older terminal records are evicted. `0` disables age eviction. Active runs are never evicted; durable `.jaiph/runs` artifacts are unaffected. Must be `>= 0`. |
diff --git a/docs/serve.md b/docs/serve.md
index 659f632f..1af46bf9 100644
--- a/docs/serve.md
+++ b/docs/serve.md
@@ -81,6 +81,8 @@ curl -s http://127.0.0.1:5247/v1/runs/$ID/artifacts/report.txt -o report.txt
The download path is resolved strictly inside the run's `artifacts/` directory and is traversal-proof: `..` segments, absolute paths, and symlinks pointing outside the directory all return `404` without touching the target file.
+Downloads stream from disk with backpressure — the server never buffers a complete artifact, so a multi-gigabyte file costs no server memory and a client disconnect closes the file immediately. To refuse oversized downloads outright, set `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap); larger artifacts return `413`.
+
## 6. Use the Swagger UI
Open `http://127.0.0.1:5247/docs` in a browser to get a live form for every workflow. When a token is configured, paste it into the **Authorize** box (Swagger UI keeps it across reloads). The UI loads its assets from a pinned, SRI-verified CDN, so `/docs` needs internet access in the browser; air-gapped operators use `/openapi.json` with any locally-hosted renderer.
diff --git a/e2e/test_all.sh b/e2e/test_all.sh
index 57cb38b7..74ad426f 100755
--- a/e2e/test_all.sh
+++ b/e2e/test_all.sh
@@ -107,6 +107,7 @@ TEST_SCRIPTS=(
"e2e/tests/149_mcp_generation_lifecycle.sh"
"e2e/tests/146_trusted_envs.sh"
"e2e/tests/148_standalone_image.sh"
+ "e2e/tests/150_k8s_deploy.sh"
"e2e/tests/210_standalone_binary.sh"
)
diff --git a/e2e/tests/150_k8s_deploy.sh b/e2e/tests/150_k8s_deploy.sh
new file mode 100755
index 00000000..a030a99e
--- /dev/null
+++ b/e2e/tests/150_k8s_deploy.sh
@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+#
+# k8s 1/1 — docs/deploy/k8s.yaml deploys, hardens, and completes a run
+# =====================================================================
+# The dry-run gate in CI only proves the manifest parses. This test proves the
+# deployment contract on a real (kind) cluster:
+#
+# - the manifest ships no credentials: the pod is blocked in
+# CreateContainerConfigError until the operator creates the external
+# `jaiph-credentials` Secret (JAIPH_SERVE_TOKEN), then starts;
+# - the pod is hardened: non-root fixed UID, no privilege escalation, all
+# capabilities dropped, RuntimeDefault seccomp, read-only root filesystem,
+# no service-account token mounted — each pinned by an assertion that
+# fails if the manifest field is removed;
+# - a real run completes: the health workflow is invoked over HTTP with
+# bearer auth, lands its journal on the writable runs volume
+# (JAIPH_RUNS_DIR) while /work stays read-only, and the events endpoint
+# byte-matches that journal.
+#
+# Gated: skips unless docker, kind, and kubectl are available. Set
+# JAIPH_E2E_KIND_CLUSTER to reuse an existing kind cluster (CI does); without
+# it the test creates and deletes its own. The user kubeconfig is never
+# touched — kubectl runs against a private kubeconfig in the test dir.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT_DIR}/e2e/lib/common.sh"
+
+K8S_MANIFEST="${ROOT_DIR}/docs/deploy/k8s.yaml"
+K8S_NAMESPACE="jaiph-e2e"
+K8S_CLUSTER="${JAIPH_E2E_KIND_CLUSTER:-}"
+K8S_OWNS_CLUSTER=0
+K8S_PORT_FORWARD_PID=""
+
+k8s::cleanup() {
+ if [[ -n "${K8S_PORT_FORWARD_PID}" ]]; then
+ kill "${K8S_PORT_FORWARD_PID}" >/dev/null 2>&1 || true
+ wait "${K8S_PORT_FORWARD_PID}" 2>/dev/null || true
+ K8S_PORT_FORWARD_PID=""
+ fi
+ if [[ "${K8S_OWNS_CLUSTER}" == "1" ]]; then
+ kind delete cluster --name "${K8S_CLUSTER}" >/dev/null 2>&1 || true
+ elif [[ -n "${K8S_CLUSTER}" && -n "${KUBECONFIG:-}" && -f "${KUBECONFIG}" ]]; then
+ # Shared cluster (CI): remove only what this test created.
+ kubectl delete namespace "${K8S_NAMESPACE}" --ignore-not-found --wait=false >/dev/null 2>&1 || true
+ fi
+ e2e::cleanup
+}
+trap k8s::cleanup EXIT
+
+e2e::prepare_test_env "k8s_deploy"
+TEST_DIR="${JAIPH_E2E_TEST_DIR}"
+
+for cmd in kind kubectl python3 curl; do
+ if ! command -v "${cmd}" >/dev/null 2>&1; then
+ e2e::section "k8s deploy manifest (skipped — ${cmd} unavailable)"
+ e2e::skip "${cmd} is not available, skipping k8s deploy test"
+ exit 0
+ fi
+done
+if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
+ e2e::section "k8s deploy manifest (skipped — Docker unavailable)"
+ e2e::skip "Docker is not available, skipping k8s deploy test"
+ exit 0
+fi
+if ! e2e::ensure_docker_test_image; then
+ e2e::section "k8s deploy manifest (skipped — test image build failed)"
+ e2e::skip "Could not build local Docker test image"
+ exit 0
+fi
+
+e2e::section "manifest ships no applyable credentials"
+
+# The base manifest must not contain a Secret (the credential contract is an
+# external `kubectl create secret`); a reintroduced placeholder fails here.
+if grep -q "^kind: Secret" "${K8S_MANIFEST}"; then
+ e2e::fail "docs/deploy/k8s.yaml must not ship a Secret manifest (external Secret contract)"
+fi
+if grep -qi "replace-me" "${K8S_MANIFEST}"; then
+ e2e::fail "docs/deploy/k8s.yaml must not ship placeholder credential values"
+fi
+e2e::pass "no Secret manifest / placeholder credentials in docs/deploy/k8s.yaml"
+
+e2e::section "kind cluster + image load"
+
+export KUBECONFIG="${TEST_DIR}/kubeconfig"
+if [[ -n "${K8S_CLUSTER}" ]]; then
+ kind export kubeconfig --name "${K8S_CLUSTER}" --kubeconfig "${KUBECONFIG}"
+else
+ K8S_CLUSTER="jaiph-e2e-$$"
+ K8S_OWNS_CLUSTER=1
+ kind create cluster --name "${K8S_CLUSTER}" --kubeconfig "${KUBECONFIG}" --wait 120s >/dev/null
+fi
+kind load docker-image "${E2E_DOCKER_TEST_IMAGE}" --name "${K8S_CLUSTER}" >/dev/null
+e2e::pass "cluster ${K8S_CLUSTER} ready with ${E2E_DOCKER_TEST_IMAGE} loaded"
+
+kubectl create namespace "${K8S_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
+kc() { kubectl -n "${K8S_NAMESPACE}" "$@"; }
+
+e2e::section "external Secret contract blocks startup until the operator provides it"
+
+# Apply the manifest verbatim except the image line: the test must run the
+# locally built image, and the manifest itself instructs pinning your own.
+sed "s|image: ghcr.io/jaiphlang/jaiph-runtime:nightly|image: ${E2E_DOCKER_TEST_IMAGE}|" \
+ "${K8S_MANIFEST}" | kc apply -f - >/dev/null
+
+# The secretRef must be required (`optional` unset), and without the Secret
+# the pod must be held in CreateContainerConfigError — never a running,
+# unauthenticated server.
+optional="$(kc get deployment jaiph-runner \
+ -o jsonpath='{.spec.template.spec.containers[0].envFrom[0].secretRef.optional}')"
+e2e::assert_equals "${optional}" "" "jaiph-credentials secretRef is required (optional unset)"
+
+blocked=""
+for _ in $(seq 1 60); do
+ blocked="$(kc get pods -l app=jaiph-runner \
+ -o jsonpath='{.items[0].status.containerStatuses[0].state.waiting.reason}' 2>/dev/null || true)"
+ if [[ "${blocked}" == "CreateContainerConfigError" ]]; then
+ break
+ fi
+ sleep 2
+done
+e2e::assert_equals "${blocked}" "CreateContainerConfigError" \
+ "pod is blocked until the external jaiph-credentials Secret exists"
+
+serve_token="e2e-$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
+kc create secret generic jaiph-credentials \
+ --from-literal=JAIPH_SERVE_TOKEN="${serve_token}" >/dev/null
+kc rollout status deployment/jaiph-runner --timeout=240s >/dev/null
+e2e::pass "pod started once the Secret was created"
+
+e2e::section "pod hardening is applied and effective"
+
+pod="$(kc get pods -l app=jaiph-runner --field-selector=status.phase=Running \
+ -o jsonpath='{.items[0].metadata.name}')"
+
+# Manifest-derived spec fields: each assertion fails if the corresponding
+# hardening line is removed from docs/deploy/k8s.yaml.
+spec_fields="$(kc get pod "${pod}" -o jsonpath='{.spec.securityContext.runAsNonRoot} {.spec.securityContext.runAsUser} {.spec.securityContext.seccompProfile.type} {.spec.automountServiceAccountToken} {.spec.containers[0].securityContext.allowPrivilegeEscalation} {.spec.containers[0].securityContext.readOnlyRootFilesystem} {.spec.containers[0].securityContext.capabilities.drop[0]}')"
+e2e::assert_equals "${spec_fields}" "true 10001 RuntimeDefault false false true ALL" \
+ "runAsNonRoot=true runAsUser=10001 seccomp=RuntimeDefault automountSAToken=false allowPrivilegeEscalation=false readOnlyRootFilesystem=true capabilities.drop=ALL"
+
+# Effective in the running container, not just declared.
+e2e::assert_equals "$(kc exec "${pod}" -- id -u)" "10001" "container runs as UID 10001 (non-root)"
+e2e::assert_equals \
+ "$(kc exec "${pod}" -- sh -c 'test -e /var/run/secrets/kubernetes.io/serviceaccount/token && echo mounted || echo absent')" \
+ "absent" "default service-account token is not mounted"
+e2e::assert_equals \
+ "$(kc exec "${pod}" -- sh -c 'touch /usr/local/e2e-probe 2>/dev/null && echo writable || echo readonly')" \
+ "readonly" "root filesystem is read-only"
+e2e::assert_equals \
+ "$(kc exec "${pod}" -- sh -c 'touch /work/e2e-probe 2>/dev/null && echo writable || echo readonly')" \
+ "readonly" "workflow sources under /work are read-only"
+
+e2e::section "authenticated HTTP run lands its journal on the writable runs volume"
+
+pf_out="${TEST_DIR}/port_forward.txt"
+: >"${pf_out}"
+kc port-forward svc/jaiph-runner :80 >"${pf_out}" 2>&1 &
+K8S_PORT_FORWARD_PID="$!"
+port=""
+for _ in $(seq 1 50); do
+ port="$(sed -nE 's#^Forwarding from 127\.0\.0\.1:([0-9]+).*#\1#p' "${pf_out}" | head -1)"
+ if [[ -n "${port}" ]]; then
+ break
+ fi
+ sleep 0.2
+done
+if [[ -z "${port}" ]]; then
+ printf 'port-forward output:\n%s\n' "$(cat "${pf_out}")" >&2
+ e2e::fail "kubectl port-forward did not report a local port"
+fi
+base="http://127.0.0.1:${port}"
+
+health_status="$(curl -s "${base}/healthz" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
+e2e::assert_equals "${health_status}" "ok" "GET /healthz reports status ok"
+
+unauth_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
+ "${base}/v1/workflows/health/runs?wait=true" -H 'content-type: application/json' -d '{}')"
+e2e::assert_equals "${unauth_code}" "401" "POST without the bearer token is rejected with 401"
+
+run_json="$(curl -s -X POST "${base}/v1/workflows/health/runs?wait=true" \
+ -H "authorization: Bearer ${serve_token}" -H 'content-type: application/json' -d '{}')"
+run_fields="$(printf '%s' "${run_json}" | python3 -c '
+import json, sys
+d = json.load(sys.stdin)
+print(d["status"])
+print(d["result_text"])
+print(d["run_id"])
+print(d["run_dir"])
+')"
+{
+ read -r p_status
+ read -r p_result
+ read -r p_run_id
+ read -r p_run_dir
+} <<< "${run_fields}"
+e2e::assert_equals "${p_status}" "succeeded" "health run status is succeeded"
+e2e::assert_equals "${p_result}" "ok" "health result_text is the workflow return value"
+case "${p_run_dir}" in
+ /jaiph/runs/*) e2e::pass "run_dir ${p_run_dir} is on the writable runs volume (JAIPH_RUNS_DIR)" ;;
+ *) e2e::fail "run_dir ${p_run_dir} is not under /jaiph/runs" ;;
+esac
+
+# The durable journal on the runs volume is the source of truth; the events
+# endpoint must serve it verbatim (full-content equality).
+journal="$(kc exec "${pod}" -- cat "${p_run_dir}/run_summary.jsonl")"
+events="$(curl -s "${base}/v1/runs/${p_run_id}/events" -H "authorization: Bearer ${serve_token}")"
+e2e::assert_equals "${events}" "${journal}" \
+ "GET events (NDJSON) byte-matches run_summary.jsonl read from the runs volume"
diff --git a/integration/serve-server.test.ts b/integration/serve-server.test.ts
index f645b456..c2ea807d 100644
--- a/integration/serve-server.test.ts
+++ b/integration/serve-server.test.ts
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
-import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { closeSync, existsSync, ftruncateSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -68,6 +68,7 @@ function serveEnv(runsRoot: string, extra?: Record): NodeJS.Proc
interface ServeProc {
baseUrl: string;
+ pid: number;
stderr: () => string;
close: () => Promise;
}
@@ -91,6 +92,7 @@ function startServe(fixture: string, cwd: string, env: NodeJS.ProcessEnv, extraA
clearTimeout(timer);
resolve({
baseUrl: m[1],
+ pid: child.pid!,
stderr: () => stderrBuf,
close: () =>
new Promise((res) => {
@@ -467,6 +469,69 @@ test("jaiph serve: artifacts round-trip — list then byte-identical download, t
}
});
+/** Resident set size of `pid` in KB via `ps` (0 when unavailable). */
+function sampleRssKb(pid: number): number {
+ const out = spawnSync("ps", ["-o", "rss=", "-p", String(pid)], { encoding: "utf8" });
+ const n = Number((out.stdout ?? "").trim());
+ return Number.isFinite(n) ? n : 0;
+}
+
+test(
+ "jaiph serve: a multi-gigabyte sparse artifact streams with bounded server memory",
+ { skip: process.platform === "win32" ? "needs ps for RSS sampling" : false },
+ async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-serve-bigart-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, BASE_FIXTURE);
+ const srv = await startServe(jh, root, serveEnv(join(root, ".jaiph/runs")));
+ try {
+ const runRes = await fetch(`${srv.baseUrl}/v1/workflows/make_artifact/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ const run = await runRes.json();
+ assert.equal(run.status, "succeeded", `run failed: ${JSON.stringify(run)}`);
+
+ // Publish a 3 GiB sparse file into the run's durable artifacts dir. It
+ // occupies no disk, but a server that buffers the download whole would
+ // hold all 3 GiB resident.
+ const SIZE = 3 * 1024 ** 3;
+ const fd = openSync(join(run.run_dir, "artifacts", "big.bin"), "w");
+ ftruncateSync(fd, SIZE);
+ closeSync(fd);
+
+ const dl = await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/artifacts/big.bin`);
+ assert.equal(dl.status, 200);
+ assert.equal(dl.headers.get("content-length"), String(SIZE));
+
+ // Consume the stream, sampling the serve process's RSS as it flows.
+ const reader = dl.body!.getReader();
+ let received = 0;
+ let maxRssKb = 0;
+ let lastSample = 0;
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ received += value!.length;
+ if (Date.now() - lastSample > 250) {
+ lastSample = Date.now();
+ maxRssKb = Math.max(maxRssKb, sampleRssKb(srv.pid));
+ }
+ }
+ assert.equal(received, SIZE, "every artifact byte arrived");
+ assert.ok(maxRssKb > 0, "RSS sampling observed the serve process during the transfer");
+ // Streaming keeps the server at a few hundred MB at most; buffering the
+ // artifact whole would put it well past 3 GiB.
+ const BOUND_KB = 1.5 * 1024 * 1024;
+ assert.ok(maxRssKb < BOUND_KB, `server RSS stayed bounded during the 3 GiB download (max ${maxRssKb} KB)`);
+ } finally {
+ await srv.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+ },
+);
+
test("jaiph --help lists jaiph serve", () => {
const result = spawnSync("node", [CLI_PATH, "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(result.status, 0);
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index 493af3b7..e4656ef1 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -27,6 +27,8 @@ const DEFAULT_RETAIN_RUNS = 500;
const DEFAULT_RETAIN_AGE_SEC = 24 * 60 * 60;
/** 1 MiB per stream / log buffer / result_text; bounds one run's memory. */
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
+/** No cap on artifact downloads by default — they stream, so size costs no memory. */
+const DEFAULT_MAX_ARTIFACT_BYTES = 0;
/**
* Parse an integer env var, returning the fallback when unset. Throws a
@@ -61,7 +63,9 @@ const SERVE_USAGE =
"(per-run stdout/stderr/log/result cap, default 1 MiB), JAIPH_SERVE_RETAIN_RUNS\n" +
"(completed runs kept in memory, default 500), and JAIPH_SERVE_RETAIN_AGE_SEC\n" +
"(max completed-run age, default 86400; 0 disables). GET /v1/runs is paginated\n" +
- "(?limit default 100, max 1000; ?offset).\n\n" +
+ "(?limit default 100, max 1000; ?offset). Artifact downloads stream with\n" +
+ "backpressure; JAIPH_SERVE_MAX_ARTIFACT_BYTES (default 0 = no cap) refuses\n" +
+ "larger files with 413.\n\n" +
" --host listen address (default: 127.0.0.1)\n" +
" --port listen port (default: 5247)\n" +
" --workspace workspace root for import resolution (default: auto-detect)\n" +
@@ -145,10 +149,12 @@ export async function runServe(rest: string[]): Promise {
let retainRuns: number;
let retainAgeSec: number;
let maxOutputBytes: number;
+ let maxArtifactBytes: number;
try {
retainRuns = intEnv(process.env.JAIPH_SERVE_RETAIN_RUNS, "JAIPH_SERVE_RETAIN_RUNS", DEFAULT_RETAIN_RUNS, 1);
retainAgeSec = intEnv(process.env.JAIPH_SERVE_RETAIN_AGE_SEC, "JAIPH_SERVE_RETAIN_AGE_SEC", DEFAULT_RETAIN_AGE_SEC, 0);
maxOutputBytes = intEnv(process.env.JAIPH_SERVE_MAX_OUTPUT_BYTES, "JAIPH_SERVE_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT_BYTES, 1);
+ maxArtifactBytes = intEnv(process.env.JAIPH_SERVE_MAX_ARTIFACT_BYTES, "JAIPH_SERVE_MAX_ARTIFACT_BYTES", DEFAULT_MAX_ARTIFACT_BYTES, 0);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return 1;
@@ -216,11 +222,14 @@ export async function runServe(rest: string[]): Promise {
maxConcurrent,
retainRuns,
retainAgeSec,
+ maxArtifactBytes,
now: () => new Date().toISOString(),
// A run's dir is only recorded on its object at finalize; while it runs the
// events/artifacts endpoints locate it by scanning the host runs root for
// the run id (works host- and Docker-side — the run dir is a host mount).
- resolveRunDir: (record) => record.run_dir ?? findRunDir(hostRunsRoot, record.run_id),
+ // The handler caches the first hit per record, so a live SSE poll loop
+ // scans at most once.
+ resolveRunDir: (record) => findRunDir(hostRunsRoot, record.run_id),
getTools: () => generations.current().tools,
callTool: (spec, args, runId, ctx) => {
// Bind the run to the generation live at start; the lease keeps its
diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts
index 7ed5c841..18d5fd49 100644
--- a/src/cli/serve/handler.test.ts
+++ b/src/cli/serve/handler.test.ts
@@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { ServeHandler, type ServeRequest, type ServeResponse } from "./handler";
+import { ServeHandler, type RunRecord, type ServeRequest, type ServeResponse } from "./handler";
import type { StreamTarget } from "./runfiles";
import type { McpToolSpec } from "../mcp/tools";
import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
@@ -44,6 +44,9 @@ function makeHandler(overrides?: {
retainRuns?: number;
retainAgeSec?: number;
now?: () => string;
+ resolveRunDir?: (record: RunRecord) => string | null;
+ ssePollMs?: number;
+ maxArtifactBytes?: number;
}): ServeHandler {
let n = 0;
return new ServeHandler({
@@ -57,6 +60,9 @@ function makeHandler(overrides?: {
retainAgeSec: overrides?.retainAgeSec,
now: overrides?.now ?? (() => "2026-07-24T00:00:00.000Z"),
newRunId: () => `run-${n++}`,
+ resolveRunDir: overrides?.resolveRunDir,
+ ssePollMs: overrides?.ssePollMs,
+ maxArtifactBytes: overrides?.maxArtifactBytes,
});
}
@@ -423,7 +429,7 @@ test("events + artifacts require the bearer token when one is configured", async
}
});
-test("NDJSON events on a terminal run byte-match the run_summary.jsonl file", async () => {
+test("NDJSON events on a terminal run stream the run_summary.jsonl file itself", async () => {
const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-ndjson-"));
try {
const journal = '{"type":"WORKFLOW_START","run_id":"r"}\n{"type":"WORKFLOW_END","run_id":"r"}\n';
@@ -432,8 +438,10 @@ test("NDJSON events on a terminal run byte-match the run_summary.jsonl file", as
const res = await h.handleRequest(req("GET", `/v1/runs/${runId}/events`));
assert.equal(res.status, 200);
assert.equal(res.headers["content-type"], "application/x-ndjson");
- assert.ok(res.bodyBuffer, "NDJSON is served as a binary body");
- assert.deepEqual(res.bodyBuffer, readFileSync(join(runDir, "run_summary.jsonl")), "byte-identical to the journal");
+ assert.ok(res.bodyFile, "NDJSON is streamed from a file, never buffered whole");
+ assert.equal(res.bodyFile!.path, join(runDir, "run_summary.jsonl"), "the streamed file is the journal");
+ assert.equal(res.bodyFile!.size, Buffer.byteLength(journal), "exactly the journal's bytes are streamed");
+ assert.equal(res.headers["content-length"], String(Buffer.byteLength(journal)));
} finally {
rmSync(runDir, { recursive: true, force: true });
}
@@ -479,7 +487,10 @@ test("artifacts round-trip through the handler: list then byte-identical downloa
assert.equal(dl.status, 200);
assert.equal(dl.headers["content-type"], "application/octet-stream");
assert.match(dl.headers["content-disposition"], /filename="out\.bin"/);
- assert.deepEqual(dl.bodyBuffer, payload, "downloaded bytes match the published file");
+ assert.equal(dl.headers["content-length"], String(payload.length));
+ assert.ok(dl.bodyFile, "the artifact is streamed from disk, never buffered whole");
+ assert.equal(dl.bodyFile!.size, payload.length);
+ assert.deepEqual(readFileSync(dl.bodyFile!.path), payload, "the streamed file is the published artifact");
// Traversal to a run-dir file (not under artifacts/) is a 404.
const escape = await h.handleRequest(req("GET", `/v1/runs/${runId}/artifacts/${encodeURIComponent("../run_summary.jsonl")}`));
@@ -489,6 +500,96 @@ test("artifacts round-trip through the handler: list then byte-identical downloa
}
});
+/** A stream target that records writes and can be aborted like a disconnecting client. */
+function abortableTarget(): StreamTarget & { chunks: string[]; abort: () => void } {
+ let aborted = false;
+ const cbs: Array<() => void> = [];
+ const chunks: string[] = [];
+ return {
+ chunks,
+ write: (c: string) => void chunks.push(c),
+ get aborted(): boolean {
+ return aborted;
+ },
+ onAbort: (cb: () => void) => void cbs.push(cb),
+ abort(): void {
+ aborted = true;
+ for (const cb of cbs) cb();
+ },
+ };
+}
+
+/** Poll until `target` holds at least `n` SSE `data:` frames. */
+async function waitForDataFrames(target: { chunks: string[] }, n: number): Promise {
+ const deadline = Date.now() + 5000;
+ while (target.chunks.filter((c) => c.startsWith("data: ")).length < n) {
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${n} SSE data frames`);
+ await new Promise((r) => setTimeout(r, 5));
+ }
+}
+
+test("a live SSE connection resolves the run dir with at most one scan", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-scan-"));
+ try {
+ const journal = join(runDir, "run_summary.jsonl");
+ writeFileSync(journal, '{"type":"WORKFLOW_START","run_id":"r"}\n');
+ let scans = 0;
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ // The run never finishes, so `run_dir` is never set on the record and
+ // every poll must go through the resolver.
+ callTool: () => new Promise(() => {}),
+ resolveRunDir: () => {
+ scans += 1;
+ return runDir;
+ },
+ ssePollMs: 5,
+ });
+ const started = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs")));
+ const res = await h.handleRequest(req("GET", `/v1/runs/${started.run_id}/events`, { headers: { accept: "text/event-stream" } }));
+ assert.ok(res.stream);
+ const target = abortableTarget();
+ const done = res.stream!(target);
+ await waitForDataFrames(target, 1);
+ // Grow the journal and wait for its frame — proof that later polls (each of
+ // which resolves the run dir) happened after the first scan.
+ appendFileSync(journal, '{"type":"STEP_END","status":0}\n');
+ await waitForDataFrames(target, 2);
+ target.abort();
+ await done;
+ assert.equal(scans, 1, "the runs tree was scanned exactly once for the whole live stream");
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("an artifact past maxArtifactBytes is 413 while a smaller one still streams", async () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-cap-"));
+ try {
+ mkdirSync(join(runDir, "artifacts"), { recursive: true });
+ writeFileSync(join(runDir, "artifacts", "big.bin"), Buffer.alloc(10, 1));
+ writeFileSync(join(runDir, "artifacts", "small.bin"), Buffer.alloc(4, 2));
+ writeFileSync(join(runDir, "run_summary.jsonl"), "");
+ const h = makeHandler({
+ tools: [NOARG_TOOL],
+ maxArtifactBytes: 4,
+ callTool: async () => ({ text: "ok", isError: false, exitStatus: 0, runDir }),
+ });
+ const started = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true")));
+
+ const big = await h.handleRequest(req("GET", `/v1/runs/${started.run_id}/artifacts/big.bin`));
+ assert.equal(big.status, 413);
+ assert.equal(bodyJson(big).error.code, "E_ARTIFACT_TOO_LARGE");
+ assert.match(bodyJson(big).error.message, /JAIPH_SERVE_MAX_ARTIFACT_BYTES/);
+
+ const small = await h.handleRequest(req("GET", `/v1/runs/${started.run_id}/artifacts/small.bin`));
+ assert.equal(small.status, 200);
+ assert.equal(small.bodyFile!.size, 4);
+ } finally {
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
test("artifacts list is empty for a run with no published files", async () => {
const runDir = mkdtempSync(join(tmpdir(), "jaiph-h-empty-"));
try {
diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts
index 0af0d6d9..a99c9fa0 100644
--- a/src/cli/serve/handler.ts
+++ b/src/cli/serve/handler.ts
@@ -1,5 +1,5 @@
import { randomUUID, timingSafeEqual } from "node:crypto";
-import { existsSync, readFileSync } from "node:fs";
+import { statSync } from "node:fs";
import { basename, join } from "node:path";
import type { McpToolSpec } from "../mcp/tools";
import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call";
@@ -40,6 +40,12 @@ export interface RunRecord {
cancel?: () => void;
/** Monotonic insertion index for newest-first listing. */
order: number;
+ /**
+ * Cached result of the injected `resolveRunDir` scan for a still-running run,
+ * so a live SSE poll loop resolves the runs tree at most once. Never part of
+ * the public run object; `run_dir` (set at finalize) takes precedence.
+ */
+ resolvedRunDir?: string;
}
/** A normalized inbound request — decoupled from `node:http` so it is unit-testable. */
@@ -60,8 +66,13 @@ export interface ServeResponse {
status: number;
headers: Record;
body: string;
- /** Binary body (artifact download / NDJSON journal); takes precedence over `body`. */
- bodyBuffer?: Buffer;
+ /**
+ * Absolute path + byte count of a file to stream as the body (artifact
+ * download / NDJSON journal). The HTTP layer pipes exactly `size` bytes with
+ * backpressure and never buffers the whole file; takes precedence over
+ * `body`.
+ */
+ bodyFile?: { path: string; size: number };
/**
* When set, the HTTP layer streams the body by driving this function instead
* of writing `body` — used for the SSE event follow. It resolves when the
@@ -107,17 +118,25 @@ export interface ServeHandlerOptions {
/** Run-id source, injectable for tests. Defaults to `randomUUID`. */
newRunId?: () => string;
/**
- * Resolve a run's host-side run directory (the one holding
- * `run_summary.jsonl` and `artifacts/`). Called live — mid-run and after —
- * for the events/artifacts endpoints. Defaults to the record's own `run_dir`
- * (populated at finalize); the server supplies a resolver that also scans the
- * runs root by run id so a still-running run is reachable.
+ * Resolve a still-running run's host-side run directory (the one holding
+ * `run_summary.jsonl` and `artifacts/`). Consulted by the events/artifacts
+ * endpoints only while the record's own `run_dir` (populated at finalize) is
+ * absent, and the first non-null result is cached on the record — so one
+ * live SSE connection scans the runs tree at most once, no matter how many
+ * times it polls. The server supplies a resolver that scans the runs root by
+ * run id.
*/
resolveRunDir?: (record: RunRecord) => string | null;
/** SSE journal-follow poll interval (ms). Defaults to 250. */
ssePollMs?: number;
/** SSE keep-alive comment cadence (ms). Defaults to 15000. */
sseKeepAliveMs?: number;
+ /**
+ * Max size in bytes of one artifact download; a larger file is refused with
+ * 413. `0` (the default) serves any size — downloads stream with
+ * backpressure, so size never translates into server memory.
+ */
+ maxArtifactBytes?: number;
}
function isTerminal(status: RunStatus): boolean {
@@ -388,16 +407,25 @@ export class ServeHandler {
return { status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify(this.toRunObject(record)) };
}
- /** Host-side run dir for a record: injected resolver, else its own `run_dir`. */
+ /**
+ * Host-side run dir for a record: its own `run_dir` (set at finalize), else
+ * the cached mid-run resolution, else one injected-resolver scan whose hit is
+ * cached on the record — repeated polls never rescan the runs tree.
+ */
private runDirFor(record: RunRecord): string | null {
- return this.opts.resolveRunDir ? this.opts.resolveRunDir(record) : record.run_dir;
+ if (record.run_dir) return record.run_dir;
+ if (record.resolvedRunDir) return record.resolvedRunDir;
+ const dir = this.opts.resolveRunDir ? this.opts.resolveRunDir(record) : null;
+ if (dir) record.resolvedRunDir = dir;
+ return dir;
}
/**
* `GET /v1/runs/{id}/events`. Default: the run's `run_summary.jsonl` as
- * `application/x-ndjson`, verbatim, then close. `Accept: text/event-stream`:
- * SSE replay + live follow until the run is terminal. The journal's own
- * redaction is the redaction guarantee; raw capture files are never served.
+ * `application/x-ndjson`, streamed verbatim (never buffered whole), then
+ * close. `Accept: text/event-stream`: SSE replay + live follow until the run
+ * is terminal. The journal's own redaction is the redaction guarantee; raw
+ * capture files are never served.
*/
private runEvents(req: ServeRequest, id: string): ServeResponse {
const record = this.runs.get(id);
@@ -407,8 +435,20 @@ export class ServeHandler {
if (!wantsSse) {
const dir = resolveRunDir();
const file = dir ? join(dir, RUN_SUMMARY) : null;
- const bodyBuffer = file && existsSync(file) ? readFileSync(file) : Buffer.alloc(0);
- return { status: 200, headers: { "content-type": "application/x-ndjson" }, body: "", bodyBuffer };
+ let size = 0;
+ if (file) {
+ try {
+ size = statSync(file).size;
+ } catch {
+ size = 0; // Absent journal serves as an empty body.
+ }
+ }
+ return {
+ status: 200,
+ headers: { "content-type": "application/x-ndjson", "content-length": String(size) },
+ body: "",
+ bodyFile: file && size > 0 ? { path: file, size } : undefined,
+ };
}
return {
status: 200,
@@ -438,10 +478,13 @@ export class ServeHandler {
/**
* `GET /v1/runs/{id}/artifacts/{path}`: download one published file as
- * `application/octet-stream`. Traversal-proof — anything escaping the run's
- * `artifacts/` dir (`..`, absolute paths, escaping symlinks) is a 404, and
- * raw `%06d-*.out`/`.err` capture files (run-dir root, not `artifacts/`) are
- * unreachable by construction.
+ * `application/octet-stream`, streamed with backpressure — the complete
+ * artifact is never buffered, so an arbitrarily large file costs no server
+ * memory. `maxArtifactBytes > 0` refuses larger files with 413.
+ * Traversal-proof — anything escaping the run's `artifacts/` dir (`..`,
+ * absolute paths, escaping symlinks) is a 404, and raw `%06d-*.out`/`.err`
+ * capture files (run-dir root, not `artifacts/`) are unreachable by
+ * construction.
*/
private downloadArtifact(id: string, rawPath: string): ServeResponse {
const record = this.runs.get(id);
@@ -456,21 +499,30 @@ export class ServeHandler {
}
const abs = resolveArtifactPath(dir, requested);
if (!abs) return this.error(404, "E_NOT_FOUND", "unknown artifact");
- let bodyBuffer: Buffer;
+ let size: number;
try {
- bodyBuffer = readFileSync(abs);
+ size = statSync(abs).size;
} catch {
return this.error(404, "E_NOT_FOUND", "unknown artifact");
}
+ const maxBytes = this.opts.maxArtifactBytes ?? 0;
+ if (maxBytes > 0 && size > maxBytes) {
+ return this.error(
+ 413,
+ "E_ARTIFACT_TOO_LARGE",
+ `artifact is ${size} bytes; downloads are capped at ${maxBytes} (JAIPH_SERVE_MAX_ARTIFACT_BYTES)`,
+ );
+ }
const filename = basename(abs).replace(/"/g, "");
return {
status: 200,
headers: {
"content-type": "application/octet-stream",
"content-disposition": `attachment; filename="${filename}"`,
+ "content-length": String(size),
},
body: "",
- bodyBuffer,
+ bodyFile: { path: abs, size },
};
}
diff --git a/src/cli/serve/runfiles.test.ts b/src/cli/serve/runfiles.test.ts
index 81249ced..906cd254 100644
--- a/src/cli/serve/runfiles.test.ts
+++ b/src/cli/serve/runfiles.test.ts
@@ -1,9 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, readSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { listArtifacts, resolveArtifactPath, streamRunEventsSse, type StreamTarget } from "./runfiles";
+import { createJournalFollower, listArtifacts, resolveArtifactPath, streamRunEventsSse, type StreamTarget } from "./runfiles";
/** A run dir with an `artifacts/` subdir and a couple of raw capture files at the root. */
function makeRunDir(): string {
@@ -188,6 +189,99 @@ test("streamRunEventsSse replays a terminal run's journal as data: frames then e
}
});
+// === journal follower: fd + offset, no rereads ===
+
+test("createJournalFollower emits only complete lines and finishes a split line without rereading", () => {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-follow-"));
+ const file = join(runDir, "run_summary.jsonl");
+ const follower = createJournalFollower();
+ try {
+ assert.deepEqual(follower.readNewLines(file), [], "missing file is not an error");
+ writeFileSync(file, '{"a":1}\n{"b":');
+ assert.deepEqual(follower.readNewLines(file), ['{"a":1}'], "the partial trailing line is withheld");
+ appendFileSync(file, '2}\n');
+ assert.deepEqual(follower.readNewLines(file), ['{"b":2}'], "the completed line is emitted from the buffered tail");
+ assert.deepEqual(follower.readNewLines(file), [], "no appended bytes, no lines");
+ } finally {
+ follower.close();
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+/** Poll until `target` holds at least `n` SSE `data:` frames. */
+async function waitForFrames(target: { chunks: string[] }, n: number): Promise {
+ const deadline = Date.now() + 5000;
+ while (target.chunks.filter((c) => c.startsWith("data: ")).length < n) {
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${n} SSE data frames`);
+ await new Promise((r) => setTimeout(r, 5));
+ }
+}
+
+test("SSE follow reads each appended journal byte exactly once, never before the current offset", async () => {
+ const runDir = makeRunDir();
+ const journal = join(runDir, "run_summary.jsonl");
+ const lines = ['{"n":1}', '{"n":2}', '{"n":3}', '{"n":4}'];
+ writeFileSync(journal, `${lines[0]}\n${lines[1]}\n`);
+ // Instrument fs: record every positional read, and catch any whole-file load
+ // of the journal (the pre-follower implementation reread it on every poll).
+ // Patch the live CJS module object (what the compiled code reads at call
+ // time) — the tsc `import *` namespace copy is getter-only.
+ const fsAny = createRequire(__filename)("node:fs") as Record;
+ const origReadSync = readSync;
+ const origReadFileSync = readFileSync;
+ const reads: Array<{ fd: number; position: number; bytes: number }> = [];
+ let wholeJournalReads = 0;
+ const target = fakeTarget();
+ let terminal = false;
+ try {
+ fsAny.readSync = (fd: number, buffer: Buffer, off: number, len: number, pos: number): number => {
+ const n = (origReadSync as (...a: unknown[]) => number)(fd, buffer, off, len, pos);
+ reads.push({ fd, position: pos, bytes: n });
+ return n;
+ };
+ fsAny.readFileSync = (...args: unknown[]): unknown => {
+ if (args[0] === journal) wholeJournalReads += 1;
+ return (origReadFileSync as (...a: unknown[]) => unknown)(...args);
+ };
+ const done = streamRunEventsSse(target, {
+ resolveRunDir: () => runDir,
+ isTerminal: () => terminal,
+ pollMs: 5,
+ keepAliveMs: 15000,
+ });
+ await waitForFrames(target, 2);
+ appendFileSync(journal, `${lines[2]}\n`);
+ await waitForFrames(target, 3);
+ appendFileSync(journal, `${lines[3]}\n`);
+ await waitForFrames(target, 4);
+ terminal = true;
+ await done;
+ } finally {
+ fsAny.readSync = origReadSync;
+ fsAny.readFileSync = origReadFileSync;
+ rmSync(runDir, { recursive: true, force: true });
+ }
+ const payloads = target.chunks.filter((c) => c.startsWith("data: ")).map((c) => c.slice(6).replace(/\n\n$/, ""));
+ assert.deepEqual(payloads, lines, "every line arrived once, in order");
+ assert.equal(wholeJournalReads, 0, "the journal is never loaded whole on a poll");
+ // The follower's fd is the one whose positional reads total the journal size.
+ const totalBytes = Buffer.byteLength(lines.map((l) => `${l}\n`).join(""));
+ const byFd = new Map>();
+ for (const r of reads) {
+ const list = byFd.get(r.fd) ?? [];
+ list.push({ position: r.position, bytes: r.bytes });
+ byFd.set(r.fd, list);
+ }
+ const followerReads = [...byFd.values()].find((rs) => rs.reduce((s, r) => s + r.bytes, 0) === totalBytes);
+ assert.ok(followerReads, "exactly the journal's byte count was read — each byte once");
+ let offset = 0;
+ for (const r of followerReads!) {
+ assert.equal(r.position, offset, "every read starts at the high-water mark — no byte before the offset is reread");
+ offset += r.bytes;
+ }
+ assert.equal(offset, totalBytes);
+});
+
test("streamRunEventsSse stops promptly when the client disconnects mid-run", async () => {
const runDir = makeRunDir();
try {
diff --git a/src/cli/serve/runfiles.ts b/src/cli/serve/runfiles.ts
index adb84abe..6b19069d 100644
--- a/src/cli/serve/runfiles.ts
+++ b/src/cli/serve/runfiles.ts
@@ -1,4 +1,4 @@
-import { existsSync, readFileSync, readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
+import { closeSync, existsSync, fstatSync, openSync, readSync, readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
import { join, resolve, sep } from "node:path";
/** A run's durable journal file name, under its run directory. */
@@ -112,28 +112,80 @@ export interface SseEventsOptions {
keepAliveMs: number;
}
-/** Read complete (newline-terminated) lines from `file` starting at byte `offset`. */
-function readNewLines(file: string, offset: number): { lines: string[]; nextOffset: number } {
- let buf: Buffer;
- try {
- buf = readFileSync(file);
- } catch {
- return { lines: [], nextOffset: offset };
- }
- if (buf.length <= offset) return { lines: [], nextOffset: offset };
- const chunk = buf.toString("utf8", offset);
- const lastNl = chunk.lastIndexOf("\n");
- if (lastNl === -1) return { lines: [], nextOffset: offset };
- const complete = chunk.slice(0, lastNl);
- const lines = complete.length > 0 ? complete.split("\n") : [];
- const nextOffset = offset + Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
- return { lines, nextOffset };
+/**
+ * Incremental reader over an append-only journal: an open file descriptor plus
+ * a byte offset, so each poll reads only the bytes appended since the previous
+ * one — bytes before the current offset are never read again, and no poll
+ * loads the whole file. Bytes after the last newline (a partial line still
+ * being written) are buffered in memory and completed on a later call, never
+ * re-read from disk.
+ */
+export interface JournalFollower {
+ /** Complete (newline-terminated) lines appended to `file` since the last call. */
+ readNewLines(file: string): string[];
+ close(): void;
+}
+
+export function createJournalFollower(): JournalFollower {
+ let fd: number | null = null;
+ let offset = 0;
+ let tail = Buffer.alloc(0);
+ return {
+ readNewLines(file: string): string[] {
+ if (fd === null) {
+ try {
+ fd = openSync(file, "r");
+ } catch {
+ return []; // The journal may not exist yet; try again next poll.
+ }
+ }
+ let size: number;
+ try {
+ size = fstatSync(fd).size;
+ } catch {
+ return [];
+ }
+ if (size <= offset) return [];
+ const appended = Buffer.alloc(size - offset);
+ let read = 0;
+ try {
+ while (read < appended.length) {
+ const n = readSync(fd, appended, read, appended.length - read, offset + read);
+ if (n === 0) break;
+ read += n;
+ }
+ } catch {
+ return [];
+ }
+ offset += read;
+ const buf = tail.length > 0 ? Buffer.concat([tail, appended.subarray(0, read)]) : appended.subarray(0, read);
+ const lastNl = buf.lastIndexOf(0x0a);
+ if (lastNl === -1) {
+ tail = Buffer.from(buf);
+ return [];
+ }
+ // Copy the partial tail out so the full appended buffer can be collected.
+ tail = Buffer.from(buf.subarray(lastNl + 1));
+ const complete = buf.toString("utf8", 0, lastNl);
+ return complete.length > 0 ? complete.split("\n") : [];
+ },
+ close(): void {
+ if (fd === null) return;
+ try {
+ closeSync(fd);
+ } catch {
+ // Already closed; nothing to release.
+ }
+ fd = null;
+ },
+ };
}
/**
* SSE follower for a run's durable journal. Replays every existing line as
* `data: `, follows the file as it appends (polling every
- * `pollMs`), keeps proxies from idling the connection out with a `:ka` comment
+ * `pollMs` through a {@link JournalFollower}, so each poll reads only the new
+ * bytes), keeps proxies from idling the connection out with a `:ka` comment
* every `keepAliveMs`, and closes with `event: end` once the run is terminal —
* so it works identically for a still-running run and an already-terminal one
* (full replay + immediate end).
@@ -143,31 +195,33 @@ function readNewLines(file: string, offset: number): { lines: string[]; nextOffs
* `.err` capture files are never opened here.
*/
export async function streamRunEventsSse(target: StreamTarget, opts: SseEventsOptions): Promise {
- let offset = 0;
+ const follower = createJournalFollower();
let lastKeepAlive = Date.now();
const flush = (): void => {
const dir = opts.resolveRunDir();
if (!dir) return;
- const { lines, nextOffset } = readNewLines(join(dir, RUN_SUMMARY), offset);
- offset = nextOffset;
- for (const line of lines) target.write(`data: ${line}\n\n`);
+ for (const line of follower.readNewLines(join(dir, RUN_SUMMARY))) target.write(`data: ${line}\n\n`);
};
- for (;;) {
- if (target.aborted) return;
- flush();
- if (opts.isTerminal()) {
- // A final line (e.g. WORKFLOW_END) may have landed between the read above
- // and the registry marking the run terminal; flush once more so the
- // stream is complete before closing.
+ try {
+ for (;;) {
+ if (target.aborted) return;
flush();
- target.write("event: end\ndata: {}\n\n");
- return;
- }
- if (Date.now() - lastKeepAlive >= opts.keepAliveMs) {
- target.write(":ka\n\n");
- lastKeepAlive = Date.now();
+ if (opts.isTerminal()) {
+ // A final line (e.g. WORKFLOW_END) may have landed between the read above
+ // and the registry marking the run terminal; flush once more so the
+ // stream is complete before closing.
+ flush();
+ target.write("event: end\ndata: {}\n\n");
+ return;
+ }
+ if (Date.now() - lastKeepAlive >= opts.keepAliveMs) {
+ target.write(":ka\n\n");
+ lastKeepAlive = Date.now();
+ }
+ await sleep(opts.pollMs, target);
}
- await sleep(opts.pollMs, target);
+ } finally {
+ follower.close();
}
}
diff --git a/src/cli/serve/server.test.ts b/src/cli/serve/server.test.ts
new file mode 100644
index 00000000..5125f24f
--- /dev/null
+++ b/src/cli/serve/server.test.ts
@@ -0,0 +1,187 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { connect, type Socket } from "node:net";
+import type { IncomingMessage, Server } from "node:http";
+import { closeSync, createReadStream, ftruncateSync, mkdirSync, mkdtempSync, openSync, realpathSync, rmSync, writeFileSync, type ReadStream } from "node:fs";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createHttpServer, listen, readBody } from "./server";
+import { ServeHandler } from "./handler";
+import type { McpToolSpec } from "../mcp/tools";
+import type { WorkflowCallResult } from "../exec/call";
+
+const NOARG_TOOL: McpToolSpec = {
+ name: "ping",
+ workflow: "ping",
+ description: "Pings.",
+ params: [],
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+};
+
+function makeHandler(callTool: () => Promise): ServeHandler {
+ let n = 0;
+ return new ServeHandler({
+ version: "0.0.0-test",
+ serverTitle: "jaiph — test.jh",
+ getTools: () => [NOARG_TOOL],
+ callTool,
+ maxConcurrent: 4,
+ now: () => "2026-07-24T00:00:00.000Z",
+ newRunId: () => `run-${n++}`,
+ });
+}
+
+function delay(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+async function waitFor(predicate: () => boolean, what: string, timeoutMs = 5000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (!predicate()) {
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
+ await delay(10);
+ }
+}
+
+function closeServer(server: Server): Promise {
+ return new Promise((r) => {
+ server.close(() => r());
+ // fetch keeps idle keep-alive connections; sever them so close() resolves.
+ server.closeAllConnections();
+ });
+}
+
+// === readBody settlement ===
+
+test("readBody settles promptly when a request closes before end", async () => {
+ const req = new EventEmitter() as unknown as IncomingMessage;
+ const pending = readBody(req);
+ (req as unknown as EventEmitter).emit("data", Buffer.from('{"partial":'));
+ (req as unknown as EventEmitter).emit("close");
+ const result = await Promise.race([pending, delay(500).then(() => "still-pending" as const)]);
+ assert.notEqual(result, "still-pending", "readBody must settle on premature close, not hang");
+ assert.deepEqual(result, { body: "", tooLarge: false, aborted: true });
+});
+
+test("readBody still resolves a complete body, with close after end being a no-op", async () => {
+ const req = new EventEmitter() as unknown as IncomingMessage;
+ const pending = readBody(req);
+ const emitter = req as unknown as EventEmitter;
+ emitter.emit("data", Buffer.from('{"a":'));
+ emitter.emit("data", Buffer.from("1}"));
+ emitter.emit("end");
+ emitter.emit("close");
+ assert.deepEqual(await pending, { body: '{"a":1}', tooLarge: false, aborted: false });
+});
+
+// === destroying a request mid-upload ===
+
+test("destroying a request mid-upload occupies no run slot and the server keeps serving", async () => {
+ let calls = 0;
+ const handler = makeHandler(async () => {
+ calls += 1;
+ return { text: "ok", isError: false, exitStatus: 0 };
+ });
+ const server = createHttpServer(handler, () => {});
+ const port = await listen(server, "127.0.0.1", 0);
+ try {
+ const socket: Socket = connect(port, "127.0.0.1");
+ await new Promise((r) => socket.on("connect", () => r()));
+ // Declare a bigger body than we send, then vanish mid-upload.
+ socket.write(
+ "POST /v1/workflows/ping/runs HTTP/1.1\r\n" +
+ "host: localhost\r\n" +
+ "content-type: application/json\r\n" +
+ "content-length: 64\r\n" +
+ "\r\n" +
+ '{"half":',
+ );
+ await delay(50);
+ socket.destroy();
+ await delay(100);
+ assert.equal(handler.inFlight(), 0, "the aborted request holds no run slot");
+ assert.equal(calls, 0, "the aborted request never started a workflow");
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`);
+ assert.equal(res.status, 200, "the server still answers after the aborted upload");
+ } finally {
+ await closeServer(server);
+ }
+});
+
+// === artifact streaming through a real socket ===
+
+/** A finished run whose run_dir points at a temp dir with one published artifact. */
+async function serveArtifact(payloadPath: string, payload: Buffer | null): Promise<{
+ server: Server;
+ port: number;
+ runId: string;
+ runDir: string;
+}> {
+ const runDir = mkdtempSync(join(tmpdir(), "jaiph-srv-art-"));
+ mkdirSync(join(runDir, "artifacts"), { recursive: true });
+ writeFileSync(join(runDir, "run_summary.jsonl"), "");
+ if (payload !== null) writeFileSync(join(runDir, "artifacts", payloadPath), payload);
+ const handler = makeHandler(async () => ({ text: "ok", isError: false, exitStatus: 0, runDir }));
+ const server = createHttpServer(handler, () => {});
+ const port = await listen(server, "127.0.0.1", 0);
+ const res = await fetch(`http://127.0.0.1:${port}/v1/workflows/ping/runs?wait=true`, { method: "POST" });
+ const runId = ((await res.json()) as { run_id: string }).run_id;
+ return { server, port, runId, runDir };
+}
+
+test("an artifact download round-trips byte-identically through a real socket with content-length", async () => {
+ // A deterministic non-trivial payload, bigger than one stream chunk.
+ const payload = Buffer.alloc(1024 * 1024);
+ for (let i = 0; i < payload.length; i += 1) payload[i] = i % 251;
+ const { server, port, runId, runDir } = await serveArtifact("blob.bin", payload);
+ try {
+ const dl = await fetch(`http://127.0.0.1:${port}/v1/runs/${runId}/artifacts/blob.bin`);
+ assert.equal(dl.status, 200);
+ assert.equal(dl.headers.get("content-length"), String(payload.length));
+ assert.deepEqual(Buffer.from(await dl.arrayBuffer()), payload, "streamed bytes match the artifact");
+ } finally {
+ await closeServer(server);
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
+
+test("disconnecting the client mid-download destroys the artifact file stream", async () => {
+ // A sparse file far larger than the socket buffers, so a non-reading client
+ // stalls the transfer on backpressure instead of letting it complete.
+ const { server, port, runId, runDir } = await serveArtifact("big.bin", null);
+ const bigPath = join(runDir, "artifacts", "big.bin");
+ const fd = openSync(bigPath, "w");
+ ftruncateSync(fd, 256 * 1024 * 1024);
+ closeSync(fd);
+ // The download endpoint serves the artifact's real path (symlinks resolved).
+ const realBigPath = realpathSync(bigPath);
+ // Patch the live CJS module object (what the compiled server reads at call
+ // time) — the tsc `import *` namespace copy is getter-only.
+ const fsAny = createRequire(__filename)("node:fs") as Record;
+ const origCreateReadStream = createReadStream;
+ const created: ReadStream[] = [];
+ try {
+ fsAny.createReadStream = (...args: unknown[]): ReadStream => {
+ const stream = (origCreateReadStream as (...a: unknown[]) => ReadStream)(...args);
+ if (args[0] === realBigPath) created.push(stream);
+ return stream;
+ };
+ const socket: Socket = connect(port, "127.0.0.1");
+ await new Promise((r) => socket.on("connect", () => r()));
+ // Never read the response: kernel + stream buffers fill and backpressure
+ // pauses the file stream mid-transfer.
+ socket.pause();
+ socket.write(`GET /v1/runs/${runId}/artifacts/big.bin HTTP/1.1\r\nhost: localhost\r\n\r\n`);
+ await waitFor(() => created.length === 1, "the artifact file stream to open");
+ assert.equal(created[0].destroyed, false, "the stalled stream stays open while the client is connected");
+ socket.destroy();
+ await waitFor(() => created[0].destroyed, "the file stream to be destroyed");
+ assert.ok(created[0].destroyed, "client disconnect closes the file stream");
+ } finally {
+ fsAny.createReadStream = origCreateReadStream;
+ await closeServer(server);
+ rmSync(runDir, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/serve/server.ts b/src/cli/serve/server.ts
index c9c80fee..76b14b08 100644
--- a/src/cli/serve/server.ts
+++ b/src/cli/serve/server.ts
@@ -1,5 +1,7 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
+import { createReadStream } from "node:fs";
+import { pipeline } from "node:stream";
import { MAX_BODY_BYTES, type ServeHandler, type ServeRequest } from "./handler";
import type { StreamTarget } from "./runfiles";
@@ -13,7 +15,13 @@ import type { StreamTarget } from "./runfiles";
export function createHttpServer(handler: ServeHandler, log: (line: string) => void): Server {
return createServer((req: IncomingMessage, res: ServerResponse) => {
readBody(req)
- .then(({ body, tooLarge }) => {
+ .then(({ body, tooLarge, aborted }) => {
+ if (aborted) {
+ // The client destroyed the request mid-body: there is no one to
+ // respond to and no work to start.
+ res.destroy();
+ return undefined;
+ }
const url = new URL(req.url ?? "/", "http://localhost");
const serveReq: ServeRequest = {
method: req.method ?? "GET",
@@ -26,6 +34,7 @@ export function createHttpServer(handler: ServeHandler, log: (line: string) => v
return handler.handleRequest(serveReq);
})
.then((response) => {
+ if (!response) return undefined;
res.writeHead(response.status, response.headers);
if (response.stream) {
// Long-lived streaming response (SSE follow): drive it over a target
@@ -35,7 +44,10 @@ export function createHttpServer(handler: ServeHandler, log: (line: string) => v
res.end();
});
}
- res.end(response.bodyBuffer ?? response.body);
+ if (response.bodyFile) {
+ return streamBodyFile(response.bodyFile, res);
+ }
+ res.end(response.body);
return undefined;
})
.catch((err) => {
@@ -48,6 +60,25 @@ export function createHttpServer(handler: ServeHandler, log: (line: string) => v
});
}
+/**
+ * Stream a file body with backpressure: `pipeline` pauses the read stream when
+ * the socket is congested and destroys it when the client disconnects, so no
+ * more than a small stream buffer of the file is ever in memory. The read is
+ * pinned to the `size` advertised in `content-length` even if the file grows
+ * mid-stream (an appending journal). Errors after the headers are sent cannot
+ * become an HTTP error; the connection is simply torn down by `pipeline`.
+ */
+function streamBodyFile(bodyFile: { path: string; size: number }, res: ServerResponse): Promise {
+ if (bodyFile.size === 0) {
+ res.end();
+ return Promise.resolve();
+ }
+ const file = createReadStream(bodyFile.path, { start: 0, end: bodyFile.size - 1 });
+ return new Promise((resolveStream) => {
+ pipeline(file, res, () => resolveStream());
+ });
+}
+
/**
* Wire a `StreamTarget` to a live socket: writes go straight to the response,
* and the client disconnecting (`req` close) flips `aborted` and fires the
@@ -78,24 +109,47 @@ function makeStreamTarget(req: IncomingMessage, res: ServerResponse): StreamTarg
};
}
-/** Read the request body as a string, flagging (and truncating) once it passes the cap. */
-function readBody(req: IncomingMessage): Promise<{ body: string; tooLarge: boolean }> {
- return new Promise((resolve, reject) => {
+/** What one request body read settled to. `aborted` means the client went away mid-body. */
+export interface ReadBodyResult {
+ body: string;
+ tooLarge: boolean;
+ aborted: boolean;
+}
+
+/**
+ * Read the request body as a string, flagging (and truncating) once it passes
+ * the cap. Always settles: a request destroyed mid-upload never emits `end`,
+ * so premature `close` / `error` resolve with `aborted: true` — releasing the
+ * buffered chunks and letting the caller drop the request without starting any
+ * work. Exported for direct unit testing of that settlement contract.
+ */
+export function readBody(req: IncomingMessage): Promise {
+ return new Promise((resolve) => {
const chunks: Buffer[] = [];
let total = 0;
let tooLarge = false;
+ let settled = false;
+ const settle = (result: ReadBodyResult): void => {
+ if (settled) return;
+ settled = true;
+ chunks.length = 0;
+ resolve(result);
+ };
req.on("data", (chunk: Buffer) => {
+ if (settled) return;
total += chunk.length;
if (total > MAX_BODY_BYTES) {
// Past the cap: stop buffering (bound memory) but keep draining so the
// socket can carry the 413 response.
tooLarge = true;
+ chunks.length = 0;
return;
}
chunks.push(chunk);
});
- req.on("end", () => resolve({ body: tooLarge ? "" : Buffer.concat(chunks).toString("utf8"), tooLarge }));
- req.on("error", reject);
+ req.on("end", () => settle({ body: tooLarge ? "" : Buffer.concat(chunks).toString("utf8"), tooLarge, aborted: false }));
+ req.on("close", () => settle({ body: "", tooLarge, aborted: true }));
+ req.on("error", () => settle({ body: "", tooLarge, aborted: true }));
});
}
From f2c92a9f1d6b3957a94dab151c910ac090491af2 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Fri, 24 Jul 2026 18:32:46 +0200
Subject: [PATCH 13/24] Feat: unify execution-policy contract across run,
serve, and MCP
Route run, serve, and MCP through one shared execution-policy parser so
sandbox posture, workspace, repeatable --env, --inplace, --unsafe, and
--yes behave identically in every mode. Reject mutually exclusive
postures and command-specific unsupported flags as usage errors before
spawning, keeping only display (--raw) and transport (--host/--port)
options command-specific.
Define a single precedence order across CLI flags, JAIPH_* env vars, and
workflow runtime metadata, resolve and print the effective posture once
at server startup, and apply it to every call while preserving the
standalone-container exception. Adopt one lifecycle-hook contract for
direct, HTTP, and MCP invocations, backed by a table-driven integration
suite and hook tests, with matching docs and env-var reference updates.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
QUEUE.md | 21 --
docs/architecture.md | 2 +-
docs/cli.md | 20 +-
docs/configuration.md | 5 +-
docs/env-vars.md | 19 +-
docs/hooks.md | 12 +-
docs/mcp.md | 6 +-
docs/serve.md | 2 +-
integration/exec-policy.test.ts | 432 ++++++++++++++++++++++
src/cli/commands/mcp.ts | 53 +--
src/cli/commands/run.ts | 33 +-
src/cli/commands/serve.ts | 43 +--
src/cli/exec/call.ts | 114 +++++-
src/cli/run/env.ts | 23 ++
src/cli/run/hooks.ts | 73 ++--
src/cli/shared/generation-posture.test.ts | 145 ++++++++
src/cli/shared/generation.ts | 71 +++-
src/cli/shared/usage.test.ts | 87 +++++
src/cli/shared/usage.ts | 85 ++++-
20 files changed, 1083 insertions(+), 165 deletions(-)
create mode 100644 integration/exec-policy.test.ts
create mode 100644 src/cli/shared/generation-posture.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 402a32f5..83749c31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@
## All changes
+- **Feat — one execution-policy contract across `jaiph run`, `jaiph serve`, and `jaiph mcp`:** the three commands now share a single flag surface, precedence order, consent model, and lifecycle-hook contract for the same execution engine, instead of `run` exposing sandbox flags while the long-lived servers required env vars and silently ignored anything they did not destructure (`jaiph serve --unsafe` and `jaiph mcp --inplace` parsed successfully but had **no effect**). **Shared flags:** `--workspace`, repeatable `--env KEY[=VALUE]`, `--inplace`, `--unsafe`, and `--yes`/`-y` mean the same thing in all three commands. `parseArgs` (`src/cli/shared/usage.ts`) now takes the invoking `command` and a `FLAG_COMMANDS` table maps each flag to the commands that accept it; a token that looks like a flag (`-…` before `--`) but belongs to another command is a **usage error that names the owner** (`--host is not a jaiph run flag (it belongs to jaiph serve)`), an unknown flag is rejected rather than swallowed as a positional (with a `jaiph run` hint to use `--` for dash-leading workflow args), and a bare `-` still stays positional. Display/transport options stay command-specific: `--target`/`--raw` remain `jaiph run` only, `--host`/`--port` remain `jaiph serve` only. **Precedence:** one documented order everywhere — CLI flags > `JAIPH_*` env vars > workflow runtime metadata > built-in defaults. A flag sets its `JAIPH_*` variable on the launched env for that process (`applySandboxFlags`, `src/cli/run/env.ts`), so the env layer stays the single source of truth sandbox resolution consumes. Contradictory posture is never resolved by precedence: `--inplace`/`JAIPH_INPLACE` together with `--unsafe`/`JAIPH_UNSAFE` fails with **`E_FLAG_CONFLICT` before anything is spawned**, in all three commands. **Resolve-once posture:** `resolveStartupPosture` (`src/cli/shared/generation.ts`) applies the server's sandbox flags, resolves Docker enablement + sandbox mode + the unsafe-host-only gate **once at startup** into a `StartupPosture`, and the shared `logStartupPosture` prints one notice (wording — and the consent story it states — cannot drift between modes). That posture is threaded to every call as an `ExecutionPosture` (`src/cli/exec/call.ts`): `callWorkflow` applies the same `applySandboxFlags` env normalization as `jaiph run` and `callWorkflowDocker` applies the resolved sandbox mode **verbatim** instead of re-deriving it from each call's env. The documented standalone-container exception is preserved — inside a container the container/pod *is* the sandbox, the runtime image bakes `JAIPH_UNSAFE=true`, and unsafe host-only proceeds with a one-line notice. **Consent:** `jaiph run` confirms `--inplace` and unsafe-host-only interactively (`--yes`/`JAIPH_INPLACE_YES` auto-confirms; required non-TTY); for `jaiph serve` / `jaiph mcp`, launching the server with the flag or env var **is** the consent (no prompt), and `--yes`/`JAIPH_INPLACE_YES` is recorded on every call's env. **One hook contract:** the four lifecycle events (`workflow_start`, `step_start`, `step_end`, `workflow_end`) now fire for direct `jaiph run`, HTTP `jaiph serve` runs, and `jaiph mcp` tool calls alike. The `step_start`/`step_end` payload builders were extracted into `stepStartHookPayload`/`stepEndHookPayload` (`src/cli/run/hooks.ts`) and shared by the run emitter and by `callWorkflow`'s `buildStepEventHandler`, which is passed to `attachOutputCollector` on **both** the host and Docker call paths so hook dispatch cannot diverge; `hooks.json` is loaded per generation (`loadMergedHooks` in `loadGeneration`) and re-read on each source reload. Mode differences are now explicit rather than accidental: `jaiph run --raw` and `jaiph test` are documented no-hook lanes. As part of unifying the contract, `workflow_start` now carries the **run id** in `workflow_id` in every mode (direct runs previously emitted it empty). Tests: `integration/exec-policy.test.ts` (new — a table-driven suite runs the same sandbox/env cases through all three modes and asserts the same effective child env and filesystem isolation, that `serve --unsafe`/`serve --inplace`/`mcp --unsafe`/`mcp --inplace` have tested effects, that conflicting posture fails before spawning, and that all four lifecycle hooks fire with the documented payloads for direct/HTTP/MCP), `src/cli/shared/generation-posture.test.ts` (new — the flag → posture mapping with injected docker seams), `src/cli/shared/usage.test.ts` (per-command flag scoping — shared flags parse identically for run/serve/mcp, another command's flags and unknown flags are usage errors, `--` passthrough and bare `-` untouched, and `printUsage` documents the shared precedence + consent once), and `src/cli/commands/run.test.ts` (`E_FLAG_CONFLICT` for `--inplace --unsafe` and for `--inplace` + `JAIPH_UNSAFE=true`, no docker exec or spawn invoked). Docs: a new [Precedence](docs/env-vars.md#precedence) section and updated `JAIPH_INPLACE` / `JAIPH_INPLACE_YES` / `JAIPH_UNSAFE` / `--env` rows in [Environment variables](docs/env-vars.md), the shared flag tables + usage-error and resolve-once notes for `jaiph run` / `jaiph mcp` / `jaiph serve` in [CLI](docs/cli.md), the precedence layer + `E_FLAG_CONFLICT` note in [Configuration](docs/configuration.md#precedence), the "one contract, three invocation modes" section and the corrected `workflow_start` payload in [Add a hook](docs/hooks.md), the sandbox-flag/precedence notes in [Serve workflows as MCP tools](docs/mcp.md) and [Serve workflows over HTTP](docs/serve.md), the hook-dispatch contract line in [Architecture](docs/architecture.md), and the shared execution-policy section in `printUsage`.
+
- **Fix — make `jaiph serve` request, event, and artifact I/O scale with bytes transferred:** the HTTP layer still performed avoidable whole-resource work on four paths, all now proportional to the bytes actually moved. **Request bodies settle on abort:** `readBody` (`src/cli/serve/server.ts`) only settled on `end`/`error`, so a client destroyed mid-upload left the promise pending forever with its buffered chunks pinned; it now settles on premature `close`/`error` with `aborted: true`, the connection handler drops the request without invoking the router, and no run slot is ever occupied (the helper is exported for direct unit testing of the settlement contract). **Run-dir resolution is cached:** the events/artifacts endpoints located a still-running run by scanning the whole `.jaiph/runs` tree (`findRunDir`) on *every* SSE poll until finalize set `run_dir`; `ServeHandler.runDirFor` now caches the first resolver hit on the record (`RunRecord.resolvedRunDir`, never part of the public run object, evicted with the record), so one live SSE connection scans at most once no matter how long it follows. **Journals follow by fd + offset:** the SSE follower reread the entire `run_summary.jsonl` from disk on every 250 ms poll; the new `createJournalFollower` (`src/cli/serve/runfiles.ts`) holds an open file descriptor and a byte offset, reads only appended bytes with positional `readSync`, buffers a partial trailing line in memory instead of rereading it, and is closed in the stream's `finally` so a disconnect releases the fd. **Artifacts (and NDJSON journals) stream:** `GET /v1/runs/{id}/artifacts/{path}` loaded the complete file into memory (`readFileSync`) before responding; the handler now returns a `bodyFile` `{path, size}` that the HTTP layer pipes through `node:stream.pipeline` — backpressure pauses the read when the socket is congested, a client disconnect destroys the file stream, `content-length` is set from the stat and the read is pinned to it (`end: size - 1`) so an appending journal can't overrun the advertised length, and a multi-gigabyte artifact costs no server memory. The NDJSON `GET /v1/runs/{id}/events` path streams the journal the same way. An explicit size policy joins the streaming: `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap, since streaming already bounds memory) refuses larger artifacts with `413 E_ARTIFACT_TOO_LARGE`. Tests (each verified to fail against the pre-fix behavior): `src/cli/serve/server.test.ts` (readBody settles promptly on premature close; a destroyed mid-upload request occupies no run slot, never starts a workflow, and the server keeps serving; a 1 MiB artifact round-trips byte-identically through a real socket with `content-length`; a stalled 256 MiB download's file stream is destroyed when the client disconnects), `src/cli/serve/handler.test.ts` (a live SSE connection resolves the run dir with exactly one scan across many polls; the artifact cap returns 413 past the limit while smaller files stream; NDJSON/artifact responses carry `bodyFile` + `content-length`), `src/cli/serve/runfiles.test.ts` (an instrumented-`fs` SSE follow proves each appended journal byte is read exactly once and never before the current offset, and that no poll loads the journal whole; the follower completes a split line from its buffered tail), and `integration/serve-server.test.ts` (a 3 GiB sparse artifact streams to a real client with the serve process's RSS sampled below 1.5 GiB throughout — a buffering server would hold 3 GiB+). Docs: a streaming-download + `JAIPH_SERVE_MAX_ARTIFACT_BYTES` note in the artifact-download step of [Serve workflows over HTTP](docs/serve.md), the `JAIPH_SERVE_MAX_ARTIFACT_BYTES` row in [Environment variables](docs/env-vars.md), the artifact/events endpoint rows plus the `413 E_ARTIFACT_TOO_LARGE` error code and serve-limits bullet in [CLI](docs/cli.md#jaiph-serve), and the `jaiph serve` usage text.
- **Fix — bound `jaiph serve` memory: per-run output caps, completed-run retention, and paginated listing:** `jaiph serve`'s concurrency cap limits *active* children but not process memory, so over a long-lived server an authenticated caller could exhaust it three ways — the in-memory run map grew forever, each completed run's `result_text` stayed resident forever, and a single run's raw stdout/stderr/`log` capture accumulated unbounded. All three are now bounded. **Per-run output caps:** a new `OutputCaps` (`src/cli/exec/call.ts`) threads a UTF-8 byte cap through the call layer; `attachOutputCollector` keeps per-stream byte counters and one-shot "cut" flags so stdout, stderr, and collected `log` output each stop accumulating at the cap, and `composeResult` caps the composed `result_text` as a final backstop. Overflow is dropped and replaced with a deterministic `TRUNCATION_MARKER` (`[jaiph: output truncated — exceeded the configured byte cap]`); the new `capBytes` helper slices on a byte boundary and drops a trailing partial multibyte char so the head stays valid UTF-8. `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) sets one value applied **independently** to each of the four channels. The default caps are effectively unbounded (`Number.MAX_SAFE_INTEGER` via `DEFAULT_OUTPUT_CAPS`), so `jaiph mcp` and direct callers are byte-for-byte unchanged — only `jaiph serve` passes finite caps. **Completed-run retention:** `ServeHandler.evictCompleted` (`src/cli/serve/handler.ts`), called after every finalize, drops terminal records past `JAIPH_SERVE_RETAIN_RUNS` (default `500`, oldest terminal `order` evicted first) and older than `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`; `0` disables age eviction). **Active (`running`) runs are never evicted**, and eviction removes only the in-memory record — the durable `run_summary.jsonl` journal and `artifacts/` on disk are untouched and remain the operator's to prune. **Bounded listing:** `GET /v1/runs` is now paginated via `?limit` (default `100`, clamped to `1000` by `clampInt`) and `?offset`, returning `{runs, total, limit, offset}`; a hostile `?limit=` can never widen the page past the maximum, so the response is never unbounded, and the monotonic unique `order` field keeps newest-first paging stable. The OpenAPI document (`src/cli/serve/openapi.ts`) gains the `limit`/`offset` parameters and the `total`/`limit`/`offset` response fields. `jaiph serve` startup parses the three env vars with a shared `intEnv` validator (a non-integer or out-of-range value prints a diagnostic to stderr and exits `1`) and logs the effective memory bounds plus the durable-artifact operator caveat. Tests: `src/cli/exec/call.test.ts` (`capBytes` verbatim vs. marked overflow and no U+FFFD split; `composeResult` caps a runaway success return and a runaway failure narrative; `attachOutputCollector` bounds stdout/stderr/logs each with a marker), `src/cli/serve/handler.test.ts` (count retention evicts only the oldest terminal records; an active run survives even past the budget; age retention evicts once past the window; pagination is bounded, newest-first stable, and reports `total`; `limit` is clamped to the maximum), and `e2e/tests/147_serve_http_api.sh` (`?limit=1` returns exactly one record while echoing the limit and full total). Concurrency, cancellation, SSE, and artifact tests continue to pass. Docs: a new [Bound memory over a long-lived server](docs/serve.md#8-bound-memory-over-a-long-lived-server) section in [Serve workflows over HTTP](docs/serve.md), the three `JAIPH_SERVE_*` rows in [Environment variables](docs/env-vars.md), and the paginated `GET /v1/runs` row plus a memory-bounds bullet in the `jaiph serve` section of [CLI](docs/cli.md#jaiph-serve).
diff --git a/QUEUE.md b/QUEUE.md
index 074dc18f..7d668a5e 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,27 +14,6 @@ Process rules:
***
-## Use one execution-policy contract across run, serve, and MCP #dev-ready
-
-The shared parser accepts flags for every command, but commands silently ignore options they do not destructure. For example, `jaiph serve --unsafe` and `jaiph mcp --inplace` parse successfully but do not apply those flags. `run` exposes sandbox flags while long-lived modes require env vars, creating different mental models for the same execution engine.
-
-Scope:
-
-- Define shared options for `--workspace`, repeatable `--env`, `--inplace`, `--unsafe`, and `--yes`; support them consistently in `run`, `serve`, and `mcp`.
-- Define and document one precedence order across CLI flags, `JAIPH_*` env vars, and workflow runtime metadata.
-- Reject mutually exclusive posture and all command-specific unsupported flags as usage errors instead of treating or ignoring them as positionals.
-- Resolve and print the effective sandbox posture once at server startup, then apply it to every call. Preserve the documented standalone-container exception where the container/pod is the sandbox.
-- Decide and implement one lifecycle-hook contract for all three modes; mode differences must be explicit rather than caused by separate execution paths.
-- Keep display-only options such as `--raw` and transport options such as `--host`/`--port` command-specific.
-
-Acceptance:
-
-- A table-driven integration suite runs the same sandbox/env cases through all three modes and observes the same effective child env and filesystem isolation.
-- `serve --unsafe`, `serve --inplace`, `mcp --unsafe`, and `mcp --inplace` have tested effects; conflicting flags fail before spawning.
-- Flags belonging to another command fail with a clear usage error.
-- Hook tests prove the documented contract for direct, HTTP, and MCP invocations.
-- Command help and env-var reference describe the same precedence and consent rules.
-
## Give every run mode complete, bounded telemetry behavior #dev-ready
Normal `jaiph run`, HTTP calls, and MCP calls invoke the shared OTLP/Sentry hook, but `jaiph run --raw` bypasses it while the docs claim every run is covered. OTLP and Sentry are awaited sequentially, so unavailable backends can hold a completed run and a service concurrency slot for up to 20 seconds despite being described as non-load-bearing.
diff --git a/docs/architecture.md b/docs/architecture.md
index be4444b7..2e10dc4e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -187,7 +187,7 @@ Beyond those two boundaries (journal copies and returned call results), redactio
## Channels and hooks in context
-Channels are validated at compile time (`validateReferences` / send RHS rules) and executed via in-memory queue and dispatch in the Node runtime; durable **`inbox/`** files under the run directory appear only for **routed** sends (audit — see [Inbox & Dispatch](inbox.md)). Hooks are CLI-only: they load from `hooks.json` and run as shell commands with JSON on stdin, driven by the same `__JAIPH_EVENT__` stream as the progress UI — see [Hooks](hooks.md).
+Channels are validated at compile time (`validateReferences` / send RHS rules) and executed via in-memory queue and dispatch in the Node runtime; durable **`inbox/`** files under the run directory appear only for **routed** sends (audit — see [Inbox & Dispatch](inbox.md)). Hooks are CLI-only: they load from `hooks.json` and run as shell commands with JSON on stdin, driven by the same `__JAIPH_EVENT__` stream as the progress UI. One dispatch contract covers all three invocation modes — interactive `jaiph run` (via the run emitter) and `jaiph serve` / `jaiph mcp` calls (via `callWorkflow`'s shared event collector), with `jaiph run --raw` and `jaiph test` documented as no-hook lanes — see [Hooks](hooks.md).
## Test runner integration (`*.test.jh` in the kernel)
diff --git a/docs/cli.md b/docs/cli.md
index 148a2255..8b3244d4 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -49,7 +49,7 @@ Compile and execute a workflow's `default` entrypoint.
jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... [--] [args...]
```
-Sandbox selection is environment-driven; there is no `--docker` flag. The boolean sandbox flags (`--inplace`, `--unsafe`, `--yes`) are CLI front-ends that mutate the launched runtime env for one run only — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables](env-vars.md).
+Sandbox selection is environment-driven; there is no `--docker` flag. The boolean sandbox flags (`--inplace`, `--unsafe`, `--yes`) are CLI front-ends that mutate the launched runtime env for one run only, and are shared verbatim with `jaiph serve` and `jaiph mcp` (one execution-policy contract; precedence: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults) — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables — Precedence](env-vars.md#precedence). Flags belonging to another command (`--host`, `--port`) and unknown flags are usage errors, never positionals.
### Flags
@@ -278,7 +278,7 @@ Implementation: re-invokes `JAIPH_INSTALL_COMMAND` (default `curl -fsSL https://
Serve a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [Serve workflows as MCP tools](/how-to/mcp) for the recipe and client-registration steps.
```text
-jaiph mcp [--workspace ] [--env KEY[=VALUE]]...
+jaiph mcp [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]...
```
`jaiph --mcp ` is an equivalent alias, dispatched after `compile` in `src/cli/index.ts`.
@@ -287,8 +287,13 @@ jaiph mcp [--workspace ] [--env KEY[=VALUE]]...
|---|---|---|
| `--workspace` | `` | Workspace root for import resolution (default: auto-detected from the file's directory). A missing value or non-directory path aborts with a specific message. |
| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env` (same forms, validation, and reserved-key rejection), resolved once at startup and applied to **every** tool call for the server's lifetime. A bare `--env KEY` unset on the host aborts server startup with `E_ENV_MISSING`. In Docker mode the pairs cross the container boundary as explicit `-e` args bypassing the allowlist, exactly as for `jaiph run --env`. |
+| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`: every tool call's Docker sandbox bind-mounts the host workspace read-write. Mutually exclusive with `--unsafe` (`E_FLAG_CONFLICT` at startup, before anything is spawned). No interactive prompt — launching the server with the flag (or env var) is the consent; the effective posture is printed once at startup and applied to every call. |
+| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`: every tool call runs on the host with no sandbox. Same startup-consent model as `--inplace`. |
+| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1` (recorded on every call's env; servers themselves never prompt). |
| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
+Flags that belong to another command (for example `--raw` or `--port`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)).
+
### Startup and exit behaviour
- Loads the module graph and runs `collectDiagnostics` (the same compile-time pass as `jaiph compile`). Any diagnostic prints `file:line:col CODE message` lines to **stderr** and exits `1`.
@@ -343,7 +348,7 @@ Tool descriptions come from the `#` comment lines directly above each workflow (
### Execution and hot reload
- Tool calls honor the same env-driven sandbox selection as `jaiph run` (`resolveDockerConfig`): Docker on macOS/Linux by default, host-only under `JAIPH_UNSAFE=true` or on Windows. The image is prepared once at startup (`checkDockerAvailable` + `prepareImage`), not per call. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`.
-- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Set `JAIPH_INPLACE=1` to bind the real workspace read-write so tool effects land live (opt-in), or `JAIPH_UNSAFE=true` to run on the host with no sandbox.
+- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Pass `--inplace` (or set `JAIPH_INPLACE=1`) to bind the real workspace read-write so tool effects land live (opt-in), or `--unsafe` (`JAIPH_UNSAFE=true`) to run on the host with no sandbox. The posture is resolved and printed once at startup and applied to every call.
- Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr.
- Calls bind to the generation (emitted scripts + serialized graph) live when they start; a superseded generation's scripts dir survives until its last in-flight call settles, so a call spanning a reload still runs its remaining steps — the same lease model `jaiph serve` uses for HTTP runs.
@@ -353,7 +358,7 @@ Tool descriptions come from the `#` comment lines directly above each workflow (
Serve a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Same exposure rules and execution layer as `jaiph mcp`, over HTTP instead of stdio. See [Serve workflows over HTTP](/how-to/serve) for the recipe.
```text
-jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]...
+jaiph serve [--host ] [--port ] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]...
```
| Flag | Argument | Effect |
@@ -362,9 +367,14 @@ jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]
| `--port` | `` | Listen port (default `5247`). `0` picks a free port. |
| `--workspace` | `` | Workspace root for import resolution (default: auto-detected). |
| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env`, resolved once at startup and applied to every run for the server's lifetime. |
+| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`: every run's Docker sandbox bind-mounts the host workspace read-write. Mutually exclusive with `--unsafe` (`E_FLAG_CONFLICT` at startup, before anything is spawned). No interactive prompt — launching the server with the flag (or env var) is the consent; the effective posture is printed once at startup and applied to every run. |
+| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`: every run executes on the host with no sandbox. Same startup-consent model as `--inplace`. |
+| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1` (recorded on every run's env; servers themselves never prompt). |
| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
-Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), one-time Docker image preparation, credential pre-flight as warnings, and a sandbox-mode notice. All logs go to stderr; one startup line prints the listen URL and the `/docs` URL.
+Flags that belong to another command (for example `--raw` or `--target`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)).
+
+Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), one-time Docker image preparation, credential pre-flight as warnings, and a sandbox-posture notice. All logs go to stderr; one startup line prints the listen URL and the `/docs` URL.
### Endpoints
diff --git a/docs/configuration.md b/docs/configuration.md
index 7bf43cae..3892b477 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -163,11 +163,12 @@ Checks are applied top to bottom; the first match wins.
| Layer | Effect |
|---|---|
-| Environment (`JAIPH_DOCKER_*`) | Highest priority for `image`, `network`, `timeout`. |
+| CLI flags (`--inplace`, `--unsafe`, `--yes` on `jaiph run` / `jaiph serve` / `jaiph mcp`) | Set the corresponding `JAIPH_*` variable on the launched env for that process, so the env layer below stays the single source of truth. |
+| Environment (`JAIPH_DOCKER_*`, `JAIPH_UNSAFE`, `JAIPH_INPLACE`) | Highest env-layer priority for `image`, `network`, `timeout`, and sandbox posture. |
| Module-level `config` (`runtime.*`) | Applies when no env override is set. |
| Built-in defaults | Lowest priority. |
-Workflow-level `config` cannot set `runtime.*` keys.
+Workflow-level `config` cannot set `runtime.*` keys. Contradictory posture (`--inplace`/`JAIPH_INPLACE` together with `--unsafe`/`JAIPH_UNSAFE`) is rejected with `E_FLAG_CONFLICT` before anything is spawned rather than resolved by precedence — see [Environment variables — Precedence](env-vars.md#precedence).
### Scoping across nested calls
diff --git a/docs/env-vars.md b/docs/env-vars.md
index fbb92d6a..ab349759 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -23,6 +23,17 @@ Symbols used below:
The table below covers every `JAIPH_*` name read from `process.env` / `env` in `src/`. It is bidirectionally pinned by the docs-lint harness — a `JAIPH_*` name added or removed in source must be added or removed here in the same change.
+### Precedence
+
+`jaiph run`, `jaiph serve`, and `jaiph mcp` share one execution-policy contract. For every policy input the resolution order is:
+
+1. **CLI flags** (`--workspace`, `--env`, `--inplace`, `--unsafe`, `--yes`) — a flag sets the corresponding `JAIPH_*` variable on that process's launched env, so the env layer below stays the single source of truth that sandbox resolution consumes.
+2. **`JAIPH_*` environment variables** (this table).
+3. **Workflow runtime metadata** — the entry file's `config { runtime { … } }` keys (for example `docker_image`, `docker_network`).
+4. **Built-in defaults.**
+
+Contradictory posture is never resolved by precedence: `--inplace` / `JAIPH_INPLACE` together with `--unsafe` / `JAIPH_UNSAFE` fails with `E_FLAG_CONFLICT` before anything is spawned, in all three commands. Long-lived servers (`jaiph serve`, `jaiph mcp`) resolve the effective posture **once at startup**, print it, and apply it to every call; `jaiph run` resolves it per run. Consent rules: `jaiph run` confirms inplace / unsafe-host-only interactively (`--yes` / `JAIPH_INPLACE_YES` auto-confirms; required non-TTY); for `jaiph serve` / `jaiph mcp`, launching the server with the flag or env var **is** the consent (no prompt). Inside a container the container itself is the sandbox, so unsafe host-only proceeds with a one-line notice — the documented standalone posture of the runtime image.
+
| Variable | Scope | Type | Default | Related config | Role |
@@ -52,8 +63,8 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_DOCKER_TIMEOUT` | host | int (seconds) | `14400` (4h) | `runtime.docker_timeout_seconds` | Container execution timeout. `0` disables. Invalid values produce `E_DOCKER_TIMEOUT`. |
| `JAIPH_INBOX_MAX_DISPATCH` | runtime | int | `1000` | — | Maximum inbox messages a single workflow frame may drain before aborting with `E_INBOX_DISPATCH_LIMIT`. |
| `JAIPH_INBOX_PARALLEL` | — | — | — | — | Unused — the runtime does not read this variable (tests assert setting it has no effect on inbox dispatch order). |
-| `JAIPH_INPLACE` | host | bool (`1` / `true`) | `false` | — | Opt into inplace sandbox mode (host workspace bind-mounted read-write). Not forwarded into the container. |
-| `JAIPH_INPLACE_YES` | host | bool (`1` / `true`) | `false` | — | Auto-confirm the destructive-edit prompt for **both** inplace and unsafe modes (`--yes` / `-y` is the flag form). Required when `JAIPH_INPLACE` **or** the unsafe host-only path (see `JAIPH_UNSAFE`) is active and stdin is not a TTY. Not forwarded into the container. |
+| `JAIPH_INPLACE` | host | bool (`1` / `true`) | `false` | — | Opt into inplace sandbox mode (host workspace bind-mounted read-write). `--inplace` is the flag form on `jaiph run`, `jaiph serve`, and `jaiph mcp` (flag wins: it sets this variable for that process). Mutually exclusive with `JAIPH_UNSAFE` / `--unsafe` (`E_FLAG_CONFLICT`). Not forwarded into the container. |
+| `JAIPH_INPLACE_YES` | host | bool (`1` / `true`) | `false` | — | Auto-confirm the destructive-edit prompt for **both** inplace and unsafe modes (`--yes` / `-y` is the flag form on `jaiph run`, `jaiph serve`, and `jaiph mcp`). Required on `jaiph run` when `JAIPH_INPLACE` **or** the unsafe host-only path (see `JAIPH_UNSAFE`) is active and stdin is not a TTY. `jaiph serve` / `jaiph mcp` never prompt: launching the server with the posture flag or env var is the consent, and the effective posture is printed once at startup. Not forwarded into the container. |
| `JAIPH_INSTALL_COMMAND` | host | string | `curl -fsSL https://jaiph.org/install \| bash` | — | Command `jaiph use` re-invokes to reinstall. |
| `JAIPH_LIB` | host | path | — | — | Removed from the product. The CLI strips it from the launched env before each run. |
| `JAIPH_META_FILE` | internal | path | — | — | Absolute path to the run-metadata file. Set on the detached workflow runner child; stripped from the parent env before launch. |
@@ -89,7 +100,7 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_SOURCE_FILE` | internal | string (basename) | entry-file basename | — | Used to name run directories. |
| `JAIPH_STDLIB` | host | path | — | — | Removed from the product. Stripped from the launched env. |
| `JAIPH_TEST_MODE` | runtime | bool (exact `"1"`) | `false` | — | Set by `jaiph test` so the runtime skips production-only branches (e.g. file-mode normalization). |
-| `JAIPH_UNSAFE` | host | bool (`true` only) | `false` | — | Disable Docker for this run; execute on the host with **no sandbox** (entire filesystem and host environment visible to scripts and agent backends). `--unsafe` is the `jaiph run` flag form. When this turns Docker off while it would otherwise be on, `jaiph run` requires consent: a TTY warning + `Continue? [y/N]` (default no), or `JAIPH_INPLACE_YES` / `--yes` non-interactively (else `E_UNSAFE_NO_CONFIRM`). No prompt when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, Windows host-only override) or on `jaiph run --raw`. The `ghcr.io/jaiphlang/jaiph-runtime` image **bakes `JAIPH_UNSAFE=true`** so it can run standalone (`docker run … jaiph run flow.jh`, or as a k8s pod) — inside the image the container is the sandbox; see [Deploy](deploy.md). |
+| `JAIPH_UNSAFE` | host | bool (`true` only) | `false` | — | Disable Docker for this run; execute on the host with **no sandbox** (entire filesystem and host environment visible to scripts and agent backends). `--unsafe` is the flag form on `jaiph run`, `jaiph serve`, and `jaiph mcp` (flag wins: it sets this variable for that process). Mutually exclusive with `JAIPH_INPLACE` / `--inplace` (`E_FLAG_CONFLICT`). When this turns Docker off while it would otherwise be on, `jaiph run` requires consent: a TTY warning + `Continue? [y/N]` (default no), or `JAIPH_INPLACE_YES` / `--yes` non-interactively (else `E_UNSAFE_NO_CONFIRM`). `jaiph serve` / `jaiph mcp` never prompt: launching the server with the flag or env var is the consent, and the effective posture is printed once at startup and applied to every call. No prompt when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, Windows host-only override) or on `jaiph run --raw`. The `ghcr.io/jaiphlang/jaiph-runtime` image **bakes `JAIPH_UNSAFE=true`** so it can run standalone (`docker run … jaiph run flow.jh`, or as a k8s pod) — inside the image the container is the sandbox and unsafe host-only proceeds with a one-line notice; see [Deploy](deploy.md). |
| `JAIPH_WORKSPACE` | host, runtime | path | autodetected | — | Workspace root. Inside Docker the host CLI overrides this to `/jaiph/workspace`. |
@@ -115,7 +126,7 @@ The host CLI checks these before spawning the runner or container when [credenti
Forwarding allowlist into the Docker container: `JAIPH_*` run-control keys (except `JAIPH_DOCKER_*`, `JAIPH_INPLACE`, and `JAIPH_INPLACE_YES`) plus the enumerated credential keys of the backends the entry file selects — `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` for `claude`, `CURSOR_API_KEY` for `cursor`, `OPENAI_API_KEY` for `codex`. Other variables in those families (for example `ANTHROPIC_BASE_URL` or `CLAUDE_CONFIG_DIR`) and everything else — including unrelated cloud credentials — are silently dropped; use `--env` below to forward one intentionally. See [Sandboxing](sandboxing.md).
-To forward a variable outside the allowlist (for example `GITHUB_TOKEN` or `AWS_ACCESS_KEY_ID`) into a specific run, use the per-key **`--env`** flag on `jaiph run` / `jaiph mcp`: `--env KEY=VALUE` sets an exact value and `--env KEY` forwards the host's current value. In host mode `--env` defines the variable on the workflow process directly; in a Docker sandbox it crosses the boundary verbatim as an explicit `-e KEY=VALUE` container arg **bypassing the allowlist above** (the flag is the per-key consent), winning over any allowlist-forwarded value for the same key. A bare `--env KEY` unset on the host aborts with `E_ENV_MISSING` before anything is spawned; invalid names give `E_ENV_INVALID`; and the sandbox-control / runtime-managed keys the CLI owns (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`, `JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_RUN_WORKFLOW`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. Values are never path-remapped. See [CLI — `jaiph run` flags](cli.md#jaiph-run).
+To forward a variable outside the allowlist (for example `GITHUB_TOKEN` or `AWS_ACCESS_KEY_ID`) into a specific run, use the per-key **`--env`** flag on `jaiph run` / `jaiph serve` / `jaiph mcp`: `--env KEY=VALUE` sets an exact value and `--env KEY` forwards the host's current value. In host mode `--env` defines the variable on the workflow process directly; in a Docker sandbox it crosses the boundary verbatim as an explicit `-e KEY=VALUE` container arg **bypassing the allowlist above** (the flag is the per-key consent), winning over any allowlist-forwarded value for the same key. A bare `--env KEY` unset on the host aborts with `E_ENV_MISSING` before anything is spawned; invalid names give `E_ENV_INVALID`; and the sandbox-control / runtime-managed keys the CLI owns (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`, `JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_RUN_WORKFLOW`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. Values are never path-remapped. See [CLI — `jaiph run` flags](cli.md#jaiph-run).
An `--env`-forwarded variable is visible to trusted `run` script/workflow steps but **not** to `prompt` agent subprocesses: every prompt backend is spawned with a fail-closed scrub that forwards only the base environment (`PATH`, `HOME`, locale, proxies, `CLAUDE_CONFIG_DIR`, …), `JAIPH_*` control keys, and that backend's own credential keys — in host mode and every Docker sandbox mode alike. See [Sandboxing — environment exposure](sandboxing.md#env-exposure).
diff --git a/docs/hooks.md b/docs/hooks.md
index 30e093a6..644d0ee1 100644
--- a/docs/hooks.md
+++ b/docs/hooks.md
@@ -13,9 +13,17 @@ This recipe wires a shell command to a workflow lifecycle event so the CLI runs
Hooks run **on the host CLI** even when the workflow runs inside Docker. The CLI dispatches them at lifecycle points — step events from parsed `__JAIPH_EVENT__` lines on the runner's stderr, plus `workflow_start` before the runner spawns and `workflow_end` after it exits — with a JSON payload on stdin per invocation.
+**One contract, three invocation modes.** The same four events (`workflow_start`, `step_start`, `step_end`, `workflow_end`) with the same payload shapes fire for:
+
+- **direct runs** — interactive `jaiph run` / `jaiph `;
+- **HTTP runs** — every `jaiph serve` workflow run, dispatched by the server process;
+- **MCP tool calls** — every `jaiph mcp` tool call, dispatched by the server process (hook stderr goes to the server's stderr; stdout stays clean for the protocol).
+
+The explicit mode differences: `jaiph run --raw` dispatches **no** hooks (transparent embedding / the Docker inner run — the host side of a Docker run still dispatches), and `jaiph test` executes workflows in-process without hooks. Servers load `hooks.json` at startup and re-read it on each source reload.
+
## Prerequisites
-- An entry `.jh` file you can run with `jaiph run` or `jaiph ` (hooks do **not** fire for `jaiph test`, `jaiph compile`, `jaiph format`, `jaiph init`, `jaiph install`, `jaiph use`, or `jaiph run --raw`).
+- An entry `.jh` file you can run with `jaiph run` / `jaiph `, or serve with `jaiph serve` / `jaiph mcp` (hooks do **not** fire for `jaiph test`, `jaiph compile`, `jaiph format`, `jaiph init`, `jaiph install`, `jaiph use`, or `jaiph run --raw`).
- `sh`, plus whatever tool the hook command needs (`jq`, `curl`, etc.).
## 1. Create the hooks file
@@ -86,7 +94,7 @@ The jq filter above drops several fields. A full `step_end` payload also include
The other events carry different fields:
-- `workflow_start` — `event`, `timestamp`, `run_path`, `workspace`. Its `workflow_id` is present but **empty**: the run id is not known until the runner reports the first step, so do not key on it here.
+- `workflow_start` — `event`, `workflow_id` (the run id in every invocation mode — direct, HTTP, and MCP), `timestamp`, `run_path`, `workspace`.
- `workflow_end` — `event`, `workflow_id`, `status` (the resolved run exit status), `elapsed_ms` (total run time), `timestamp`, `run_path`, `workspace`, and, when the runner reported them, `run_dir` (the run directory under `.jaiph/runs`) and `summary_file` (path to `run_summary.jsonl`). These two point a webhook at the run artifacts.
Every command also inherits the CLI's environment, which is why `$HOME` resolves in the examples above.
diff --git a/docs/mcp.md b/docs/mcp.md
index d57ee839..7d29816b 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -161,11 +161,13 @@ Tool calls honor the **same env-driven Docker sandbox as `jaiph run`** ([Sandbox
**The workspace is isolated by default** — the same as `jaiph run`. Each tool call's container works on its own writable point-in-time snapshot of the workspace; edits are discarded when the container exits and the host workspace is untouched. Concurrent calls each get their own run id and run directory.
-To **opt into live writes**, set `JAIPH_INPLACE=1` before starting the server. In inplace mode the host workspace is bind-mounted read-write into each tool call's container, so effects land live — two calls that mutate the *same* files can still race.
+To **opt into live writes**, pass `--inplace` (or set `JAIPH_INPLACE=1`) when starting the server. In inplace mode the host workspace is bind-mounted read-write into each tool call's container, so effects land live — two calls that mutate the *same* files can still race.
Other sandbox controls:
-- `JAIPH_UNSAFE=true` — run on the host with no sandbox at all.
+- `--unsafe` / `JAIPH_UNSAFE=true` — run on the host with no sandbox at all.
+
+The sandbox flags are the shared execution-policy surface of `jaiph run` / `jaiph serve` / `jaiph mcp` (precedence: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults; `--inplace` + `--unsafe` is `E_FLAG_CONFLICT` at startup). The server resolves the posture **once at startup**, prints it, and applies it to every call — there is no interactive confirmation: launching the server with the flag or env var is the consent. See [Environment variables — Precedence](env-vars.md#precedence).
Agent-credential pre-flight runs once at startup. In MCP mode its findings are demoted to warnings even in Docker mode (the server can outlive a credential fix, and per-call failures still surface to the client); set credentials on the host so the allowlist forwards them into the container.
diff --git a/docs/serve.md b/docs/serve.md
index 1af46bf9..7644c5f7 100644
--- a/docs/serve.md
+++ b/docs/serve.md
@@ -108,7 +108,7 @@ The concurrency cap limits *active* children, not process memory. A long-lived s
**Eviction is in-memory only.** Dropping a run from the registry does **not** delete its durable `.jaiph/runs//run_summary.jsonl` journal or published `artifacts/` — those persist on disk (via `JAIPH_RUNS_DIR`, an `emptyDir` or PVC under Kubernetes) and are **the operator's to prune**. Once a run is evicted its API endpoints (`GET /v1/runs/{id}`, `/events`, `/artifacts`) return `404`; read the durable artifacts from the filesystem instead.
-Execution honors the same env-driven sandbox as [`jaiph run`](cli.md#jaiph-run) and [Run in a Docker sandbox](/how-to/sandbox-run): a Docker sandbox with an isolated workspace by default. `JAIPH_INPLACE=1` keeps the sandbox but binds the real workspace read-write so run effects land live; `JAIPH_UNSAFE=true` runs on the host with no sandbox at all. Publish files a run produces with [artifacts](/how-to/artifacts). Editing a served source hot-reloads the workflow set (and the OpenAPI document) with no restart; runs already in flight keep running.
+Execution honors the same execution-policy contract as [`jaiph run`](cli.md#jaiph-run) and [Run in a Docker sandbox](/how-to/sandbox-run): a Docker sandbox with an isolated workspace by default. `--inplace` (`JAIPH_INPLACE=1`) keeps the sandbox but binds the real workspace read-write so run effects land live; `--unsafe` (`JAIPH_UNSAFE=true`) runs on the host with no sandbox at all. The two are mutually exclusive (`E_FLAG_CONFLICT` at startup), the posture is resolved and printed once at startup and applied to every run, and launching the server with the flag or env var is the consent (no interactive prompt) — see [Environment variables — Precedence](env-vars.md#precedence). Publish files a run produces with [artifacts](/how-to/artifacts). Editing a served source hot-reloads the workflow set (and the OpenAPI document) with no restart; runs already in flight keep running.
## Verification
diff --git a/integration/exec-policy.test.ts b/integration/exec-policy.test.ts
new file mode 100644
index 00000000..984cd8d1
--- /dev/null
+++ b/integration/exec-policy.test.ts
@@ -0,0 +1,432 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+
+/**
+ * One execution-policy contract across `jaiph run`, `jaiph serve`, and
+ * `jaiph mcp` — the table below drives the same sandbox/env cases through all
+ * three modes and asserts the same effective child env, the same filesystem
+ * outcome, and the same fail-before-spawn behavior for conflicting posture.
+ *
+ * Everything here is host-mode (hermetic, no Docker daemon): `--unsafe` and
+ * Docker-off-by-config are exercised end to end, and the flag → env → posture
+ * mapping for `--inplace` is pinned by unit tests with docker seams
+ * (`src/cli/shared/generation-posture.test.ts`) plus the Docker e2e lane
+ * (`e2e/tests/141_mcp_docker_sandbox.sh`), which proves snapshot/inplace
+ * isolation against a real daemon.
+ */
+
+const CLI_PATH = join(process.cwd(), "dist/src/cli.js");
+
+// `env_probe` reports the passthrough keys and the posture control vars the
+// child observes; `write_marker` makes filesystem isolation observable.
+const FIXTURE = [
+ "script env_probe = `printf '%s|%s|%s|%s' \"${PROBE_A:-unset}\" \"${PROBE_B:-unset}\" \"${JAIPH_UNSAFE:-unset}\" \"${JAIPH_INPLACE:-unset}\"`",
+ 'script write_marker = `printf \'marker\' > "$JAIPH_WORKSPACE/written.txt"`',
+ "# Writes a workspace marker, then reports probe env values and posture vars.",
+ "workflow probe_and_write() {",
+ " run write_marker()",
+ " const seen = run env_probe()",
+ " return seen",
+ "}",
+ "",
+ "workflow default() {",
+ " const seen = run probe_and_write()",
+ " return seen",
+ "}",
+ "",
+].join("\n");
+
+/** Sandbox-control keys that must not leak in from the test-runner env. */
+const CONTROL_KEYS = ["JAIPH_UNSAFE", "JAIPH_INPLACE", "JAIPH_INPLACE_YES", "JAIPH_DOCKER_ENABLED", "PROBE_A", "PROBE_B"];
+
+function cleanEnv(extra: Record): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}` };
+ for (const key of CONTROL_KEYS) delete env[key];
+ return { ...env, ...extra };
+}
+
+function makeWorkspace(): { ws: string; fixture: string } {
+ const ws = mkdtempSync(join(tmpdir(), "jaiph-exec-policy-"));
+ const fixture = join(ws, "tools.jh");
+ writeFileSync(fixture, FIXTURE);
+ return { ws, fixture };
+}
+
+interface ModeOutcome {
+ /** Process (run) or server-startup (serve/mcp) exit code; null = server served fine. */
+ exitCode: number | null;
+ /** Workflow result text (return value) when the call succeeded. */
+ resultText?: string;
+ stderr: string;
+}
+
+/** Direct mode: `jaiph run tools.jh`; result text is the printed return value. */
+function runDirect(ws: string, fixture: string, flags: string[], env: NodeJS.ProcessEnv): ModeOutcome {
+ const r = spawnSync("node", [CLI_PATH, "run", ...flags, fixture], { cwd: ws, env, encoding: "utf8", timeout: 60_000 });
+ const lines = r.stdout.split("\n").filter((l) => l.trim().length > 0);
+ return { exitCode: r.status, resultText: r.status === 0 ? lines[lines.length - 1] : undefined, stderr: r.stderr };
+}
+
+/** HTTP mode: start `jaiph serve`, POST the workflow with ?wait=true, shut down. */
+async function runServeMode(ws: string, fixture: string, flags: string[], env: NodeJS.ProcessEnv): Promise {
+ const child = spawn("node", [CLI_PATH, "serve", "--port", "0", ...flags, fixture], {
+ cwd: ws,
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stderrBuf = "";
+ child.stderr!.setEncoding("utf8");
+ child.stderr!.on("data", (chunk: string) => { stderrBuf += chunk; });
+
+ const started = await new Promise<{ baseUrl?: string; exitCode?: number }>((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`serve neither listened nor exited\nstderr:\n${stderrBuf}`)), 30_000);
+ child.stderr!.on("data", () => {
+ const m = stderrBuf.match(/listening on (http:\/\/[^ ]+)/);
+ if (m) { clearTimeout(timer); resolve({ baseUrl: m[1] }); }
+ });
+ // "close" (not "exit") so a startup failure's stderr is fully flushed.
+ child.on("close", (code) => { clearTimeout(timer); resolve({ exitCode: code ?? 1 }); });
+ });
+ if (started.baseUrl === undefined) {
+ return { exitCode: started.exitCode ?? 1, stderr: stderrBuf };
+ }
+ try {
+ const res = await fetch(`${started.baseUrl}/v1/workflows/probe_and_write/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ const run = (await res.json()) as { status: string; result_text: string };
+ assert.equal(run.status, "succeeded", `serve run failed: ${run.result_text}`);
+ return { exitCode: null, resultText: run.result_text, stderr: stderrBuf };
+ } finally {
+ await new Promise((resolve) => {
+ child.on("exit", () => resolve());
+ child.kill("SIGTERM");
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
+ });
+ }
+}
+
+/** Minimal newline-JSON-RPC client for `jaiph mcp` over stdio. */
+function startMcpClient(args: string[], cwd: string, env: NodeJS.ProcessEnv): {
+ child: ChildProcessWithoutNullStreams;
+ waitFor: (predicate: (m: Record) => boolean, label: string) => Promise>;
+ send: (m: Record) => void;
+ stderr: () => string;
+ close: () => Promise;
+} {
+ const child = spawn("node", [CLI_PATH, "mcp", ...args], { cwd, env, stdio: ["pipe", "pipe", "pipe"] }) as ChildProcessWithoutNullStreams;
+ // A startup failure (e.g. E_FLAG_CONFLICT) exits before reading stdin; the
+ // client's writes then EPIPE, which must not crash the test process.
+ child.stdin.on("error", () => {});
+ const messages: Record[] = [];
+ const waiters: Array<{ predicate: (m: Record) => boolean; resolve: (m: Record) => void }> = [];
+ let stdoutBuf = "";
+ let stderrBuf = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ stdoutBuf += chunk;
+ let idx = stdoutBuf.indexOf("\n");
+ while (idx !== -1) {
+ const line = stdoutBuf.slice(0, idx);
+ stdoutBuf = stdoutBuf.slice(idx + 1);
+ if (line.length > 0) messages.push(JSON.parse(line) as Record);
+ idx = stdoutBuf.indexOf("\n");
+ }
+ for (let i = 0; i < waiters.length; i += 1) {
+ const m = messages.find((msg) => waiters[i].predicate(msg));
+ if (m) {
+ const w = waiters.splice(i, 1)[0];
+ i -= 1;
+ w.resolve(m);
+ }
+ }
+ });
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk: string) => { stderrBuf += chunk; });
+ return {
+ child,
+ send: (m) => child.stdin.write(`${JSON.stringify(m)}\n`),
+ waitFor: (predicate, label) =>
+ new Promise((resolve, reject) => {
+ const found = messages.find(predicate);
+ if (found) return resolve(found);
+ const timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}\nstderr:\n${stderrBuf}`)), 30_000);
+ waiters.push({ predicate, resolve: (m) => { clearTimeout(timer); resolve(m); } });
+ }),
+ stderr: () => stderrBuf,
+ close: () =>
+ new Promise((resolve) => {
+ // A startup failure already exited before close() is called; waiting
+ // for another "exit" would hang forever.
+ if (child.exitCode !== null || child.signalCode !== null) return resolve();
+ child.on("exit", () => resolve());
+ child.stdin.end();
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
+ }),
+ };
+}
+
+/** MCP mode: initialize, tools/call probe_and_write, read the text result. */
+async function runMcpMode(ws: string, fixture: string, flags: string[], env: NodeJS.ProcessEnv): Promise {
+ const client = startMcpClient([...flags, fixture], ws, env);
+ // "close" (not "exit") so a startup failure's stderr is fully flushed.
+ const earlyExit = new Promise((resolve) => client.child.on("close", (code) => resolve(code ?? 1)));
+ try {
+ client.send({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "0" } } });
+ const first = await Promise.race([client.waitFor((m) => m.id === 0, "initialize"), earlyExit]);
+ if (typeof first === "number" || first === null) {
+ return { exitCode: first ?? 1, stderr: client.stderr() };
+ }
+ client.send({ jsonrpc: "2.0", method: "notifications/initialized" });
+ client.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe_and_write", arguments: {} } });
+ const call = await client.waitFor((m) => m.id === 1, "tools/call response");
+ const result = call.result as { content: Array<{ text: string }>; isError: boolean };
+ assert.equal(result.isError, false, `mcp call failed: ${JSON.stringify(result.content)}`);
+ return { exitCode: null, resultText: result.content[0].text, stderr: client.stderr() };
+ } finally {
+ await client.close();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Table: the same sandbox/env case observed through all three modes
+// ---------------------------------------------------------------------------
+
+interface PolicyCase {
+ name: string;
+ flags: string[];
+ env: Record;
+ /** Expected `PROBE_A|PROBE_B|JAIPH_UNSAFE|JAIPH_INPLACE` as the child sees it. */
+ expectProbe: string;
+ /** Expected startup-posture fragment on serve/mcp stderr. */
+ expectPosture: string;
+}
+
+const POLICY_CASES: PolicyCase[] = [
+ {
+ name: "--env passthrough (explicit value + host forward), Docker off by config",
+ flags: ["--env", "PROBE_A=va", "--env", "PROBE_B"],
+ env: { PROBE_B: "vb", JAIPH_DOCKER_ENABLED: "false" },
+ expectProbe: "va|vb|unset|unset",
+ expectPosture: "execute on the host with no sandbox.",
+ },
+ {
+ name: "--unsafe --yes (unsafe opt-in turns Docker off; consent recorded)",
+ flags: ["--unsafe", "--yes"],
+ env: {},
+ expectProbe: "unset|unset|true|unset",
+ expectPosture: "execute on the host with no sandbox (unsafe opt-in",
+ },
+ {
+ // With Docker off by config the inplace *mount* is moot, but the flag's
+ // env normalization (JAIPH_INPLACE=1 on the child) must still be identical
+ // across modes — the Docker-side effect of that env value is pinned by
+ // generation-posture unit tests and the Docker e2e lane.
+ name: "--inplace normalizes JAIPH_INPLACE=1 onto every child, Docker off by config",
+ flags: ["--inplace"],
+ env: { JAIPH_DOCKER_ENABLED: "false" },
+ expectProbe: "unset|unset|unset|1",
+ expectPosture: "execute on the host with no sandbox.",
+ },
+];
+
+for (const c of POLICY_CASES) {
+ test(`exec policy table: ${c.name} — identical child env and workspace writes in run, serve, and mcp`, async () => {
+ const observations: Array<{ mode: string; probe: string; markerWritten: boolean }> = [];
+ for (const mode of ["run", "serve", "mcp"] as const) {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const env = cleanEnv(c.env);
+ const outcome =
+ mode === "run"
+ ? runDirect(ws, fixture, c.flags, env)
+ : mode === "serve"
+ ? await runServeMode(ws, fixture, c.flags, env)
+ : await runMcpMode(ws, fixture, c.flags, env);
+ assert.notEqual(outcome.exitCode ?? 0, 1, `${mode} must succeed\nstderr:\n${outcome.stderr}`);
+ assert.equal(outcome.resultText, c.expectProbe, `${mode}: effective child env`);
+ if (mode !== "run") {
+ assert.match(outcome.stderr, new RegExp(c.expectPosture.replace(/[()|\\]/g, "\\$&")), `${mode}: startup posture printed once`);
+ }
+ observations.push({ mode, probe: outcome.resultText ?? "", markerWritten: existsSync(join(ws, "written.txt")) });
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+ }
+ // The contract: every mode observed the same child env and the same
+ // filesystem outcome (host mode → the marker lands in the workspace).
+ for (const o of observations) {
+ assert.equal(o.probe, observations[0].probe, `${o.mode} matches run's child env`);
+ assert.equal(o.markerWritten, true, `${o.mode}: workspace write landed (host mode has no isolation)`);
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Conflicting posture fails before anything spawns — all three modes
+// ---------------------------------------------------------------------------
+
+test("exec policy: --inplace + --unsafe conflict fails before spawning in run, serve, and mcp", async () => {
+ for (const mode of ["run", "serve", "mcp"] as const) {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const env = cleanEnv({});
+ const flags = ["--inplace", "--unsafe", "--yes"];
+ let outcome: ModeOutcome;
+ if (mode === "run") {
+ outcome = runDirect(ws, fixture, flags, env);
+ } else if (mode === "serve") {
+ outcome = await runServeMode(ws, fixture, flags, env);
+ } else {
+ outcome = await runMcpMode(ws, fixture, flags, env);
+ }
+ assert.equal(outcome.exitCode, 1, `${mode} exits 1 on the posture conflict`);
+ assert.match(outcome.stderr, /E_FLAG_CONFLICT/, `${mode} names the conflict`);
+ assert.equal(existsSync(join(ws, "written.txt")), false, `${mode}: nothing spawned, no workspace write`);
+ assert.equal(existsSync(join(ws, ".jaiph", "runs")), false, `${mode}: no run directory was created`);
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Flags belonging to another command are usage errors
+// ---------------------------------------------------------------------------
+
+const WRONG_FLAG_CASES: Array<{ argv: string[]; expect: RegExp }> = [
+ { argv: ["run", "--host", "127.0.0.1"], expect: /--host is not a jaiph run flag.*jaiph serve/ },
+ { argv: ["run", "--port", "80"], expect: /--port is not a jaiph run flag.*jaiph serve/ },
+ { argv: ["serve", "--target", "/tmp/x"], expect: /--target is not a jaiph serve flag.*jaiph run/ },
+ { argv: ["serve", "--raw"], expect: /--raw is not a jaiph serve flag.*jaiph run/ },
+ { argv: ["mcp", "--raw"], expect: /--raw is not a jaiph mcp flag.*jaiph run/ },
+ { argv: ["mcp", "--port", "80"], expect: /--port is not a jaiph mcp flag.*jaiph serve/ },
+ { argv: ["run", "--bogus"], expect: /unknown flag --bogus for jaiph run/ },
+ { argv: ["serve", "--bogus"], expect: /unknown flag --bogus for jaiph serve/ },
+];
+
+for (const c of WRONG_FLAG_CASES) {
+ test(`exec policy: jaiph ${c.argv.join(" ")} is a usage error`, () => {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const r = spawnSync("node", [CLI_PATH, ...c.argv, fixture], { cwd: ws, env: cleanEnv({}), encoding: "utf8", timeout: 30_000 });
+ assert.equal(r.status, 1);
+ assert.match(r.stderr, c.expect);
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle-hook contract: the same four events, same payload fields, in all
+// three invocation modes (direct, HTTP, MCP)
+// ---------------------------------------------------------------------------
+
+const HOOK_EVENTS = ["workflow_start", "step_start", "step_end", "workflow_end"] as const;
+
+function writeHooksConfig(ws: string): string {
+ const hooksLog = join(ws, "hooks.log");
+ mkdirSync(join(ws, ".jaiph"), { recursive: true });
+ // Buffer the payload, then append payload+newline in ONE write — concurrent
+ // hook processes append to the same log, and two-step appends interleave.
+ const appender = ['p=$(cat); printf \'%s\\n\' "$p" >> "$HOOKS_LOG"'];
+ writeFileSync(
+ join(ws, ".jaiph", "hooks.json"),
+ JSON.stringify({ workflow_start: appender, step_start: appender, step_end: appender, workflow_end: appender }),
+ );
+ return hooksLog;
+}
+
+function readHookEvents(hooksLog: string): Array> {
+ if (!existsSync(hooksLog)) return [];
+ const events: Array> = [];
+ for (const line of readFileSync(hooksLog, "utf8").split("\n")) {
+ if (line.trim().length === 0) continue;
+ try {
+ events.push(JSON.parse(line) as Record);
+ } catch {
+ // A line still being written when we polled; the next poll re-reads it.
+ }
+ }
+ return events;
+}
+
+async function waitForHookEvents(hooksLog: string): Promise>> {
+ const deadline = Date.now() + 20_000;
+ for (;;) {
+ const events = readHookEvents(hooksLog);
+ const seen = new Set(events.map((e) => e.event));
+ if (HOOK_EVENTS.every((name) => seen.has(name))) return events;
+ if (Date.now() > deadline) {
+ throw new Error(`hooks log incomplete; saw: ${events.map((e) => e.event).join(", ") || "(nothing)"}`);
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+}
+
+/** The documented contract every mode must satisfy for its hook stream. */
+function assertHookContract(events: Array>, fixture: string, ws: string, mode: string): void {
+ const byType = (name: string) => events.filter((e) => e.event === name);
+ for (const name of HOOK_EVENTS) {
+ assert.ok(byType(name).length > 0, `${mode}: ${name} hook fired`);
+ }
+ for (const e of events) {
+ assert.equal(e.run_path, fixture, `${mode}: run_path on every payload`);
+ assert.equal(e.workspace, ws, `${mode}: workspace on every payload`);
+ assert.ok(typeof e.workflow_id === "string" && e.workflow_id.length > 0, `${mode}: non-empty workflow_id on ${e.event}`);
+ assert.ok(typeof e.timestamp === "string" && e.timestamp.length > 0, `${mode}: timestamp on ${e.event}`);
+ }
+ assert.equal(new Set(events.map((e) => e.workflow_id)).size, 1, `${mode}: one run id across all events`);
+ for (const e of [...byType("step_start"), ...byType("step_end")]) {
+ assert.ok(typeof e.step_kind === "string" && (e.step_kind as string).length > 0, `${mode}: step_kind present`);
+ assert.ok(typeof e.step_name === "string" && (e.step_name as string).length > 0, `${mode}: step_name present`);
+ }
+ const scriptSteps = byType("step_end").map((e) => `${e.step_kind} ${e.step_name}`);
+ assert.ok(scriptSteps.includes("script env_probe"), `${mode}: script step_end observed (saw: ${scriptSteps.join(", ")})`);
+ const end = byType("workflow_end")[0];
+ assert.equal(end.status, 0, `${mode}: workflow_end status 0 on success`);
+ assert.ok(typeof end.elapsed_ms === "number", `${mode}: workflow_end elapsed_ms`);
+}
+
+test("hook contract: direct `jaiph run` dispatches all four events with the documented payloads", async () => {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const hooksLog = writeHooksConfig(ws);
+ const outcome = runDirect(ws, fixture, [], cleanEnv({ JAIPH_DOCKER_ENABLED: "false", HOOKS_LOG: hooksLog }));
+ assert.equal(outcome.exitCode, 0, `run failed:\n${outcome.stderr}`);
+ assertHookContract(await waitForHookEvents(hooksLog), fixture, ws, "run");
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+});
+
+test("hook contract: HTTP `jaiph serve` runs dispatch the same four events", async () => {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const hooksLog = writeHooksConfig(ws);
+ const outcome = await runServeMode(ws, fixture, [], cleanEnv({ JAIPH_DOCKER_ENABLED: "false", HOOKS_LOG: hooksLog }));
+ assert.notEqual(outcome.exitCode, 1, `serve failed:\n${outcome.stderr}`);
+ assertHookContract(await waitForHookEvents(hooksLog), fixture, ws, "serve");
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+});
+
+test("hook contract: MCP tool calls dispatch the same four events", async () => {
+ const { ws, fixture } = makeWorkspace();
+ try {
+ const hooksLog = writeHooksConfig(ws);
+ const outcome = await runMcpMode(ws, fixture, [], cleanEnv({ JAIPH_DOCKER_ENABLED: "false", HOOKS_LOG: hooksLog }));
+ assert.notEqual(outcome.exitCode, 1, `mcp failed:\n${outcome.stderr}`);
+ assertHookContract(await waitForHookEvents(hooksLog), fixture, ws, "mcp");
+ } finally {
+ rmSync(ws, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts
index 91991243..5d34a23d 100644
--- a/src/cli/commands/mcp.ts
+++ b/src/cli/commands/mcp.ts
@@ -13,13 +13,15 @@ import {
createGenerationTracker,
createSourceWatcher,
resolveStartupPosture,
+ logStartupPosture,
WATCH_INTERVAL_MS,
type GenerationTracker,
+ type StartupPosture,
} from "../shared/generation";
import { VERSION } from "../../version";
const MCP_USAGE =
- "Usage: jaiph mcp [--workspace ] \n\n" +
+ "Usage: jaiph mcp [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... \n\n" +
"Serve the file's workflows as MCP tools over stdio (newline-delimited JSON-RPC).\n" +
"Exposure: `export workflow` declarations if any exist, otherwise every top-level\n" +
"workflow except channel route targets. `default` is exposed only when it is the\n" +
@@ -28,10 +30,21 @@ const MCP_USAGE =
"Sources are re-validated on change and clients get notifications/tools/list_changed.\n\n" +
"Tool calls honor the same env-driven Docker sandbox as `jaiph run`: the workspace\n" +
"is isolated by default via a writable point-in-time snapshot taken at call start.\n" +
- "Set JAIPH_INPLACE=1 to bind the live workspace read-write (effects land on the\n" +
- "host), or JAIPH_UNSAFE=true to run on the host with no sandbox.\n\n" +
+ "Use --inplace (JAIPH_INPLACE=1) to bind the live workspace read-write (effects land\n" +
+ "on the host), or --unsafe (JAIPH_UNSAFE=true) to run on the host with no sandbox.\n\n" +
" --workspace workspace root for import resolution (default: auto-detect)\n" +
+ " --env KEY=VALUE define KEY in every tool call's env (repeatable); --env KEY forwards the host value\n" +
+ " --inplace Docker sandbox with the host workspace bind-mounted rw for every call (JAIPH_INPLACE=1)\n" +
+ " --unsafe every call runs on the host with no sandbox (JAIPH_UNSAFE=true)\n" +
+ " -y, --yes record auto-consent for the posture (JAIPH_INPLACE_YES=1)\n" +
" -h, --help show this help\n\n" +
+ "Execution policy: --workspace/--env/--inplace/--unsafe/--yes are shared with jaiph run and\n" +
+ "jaiph serve. Precedence: CLI flags > JAIPH_* env vars > workflow config metadata > defaults.\n" +
+ "--inplace and --unsafe conflict (E_FLAG_CONFLICT, at startup before anything is spawned).\n" +
+ "The effective sandbox posture is resolved and printed once at startup and applied to every\n" +
+ "tool call; launching the server with the flag or env var is the consent (no interactive\n" +
+ "prompt). Inside a container the container itself is the sandbox (the runtime image bakes\n" +
+ "JAIPH_UNSAFE=true), so host-only execution there is the documented standalone posture.\n\n" +
"Example:\n" +
" claude mcp add mytools -- jaiph mcp ./tools.jh\n";
@@ -42,12 +55,13 @@ export async function runMcp(rest: string[]): Promise {
}
let parsed: ReturnType;
try {
- parsed = parseArgs(rest);
+ parsed = parseArgs(rest, "mcp");
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return 1;
}
- const { workspace, env, positional } = parsed;
+ const { workspace, env, positional, inplace, unsafe, yes } = parsed;
+ const sandboxFlags = { inplace, unsafe, yes };
const input = positional[0];
if (!input) {
process.stderr.write("jaiph mcp requires a .jh file path\n");
@@ -82,7 +96,7 @@ export async function runMcp(rest: string[]): Promise {
let generation = 0;
let generations: GenerationTracker;
try {
- const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp");
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp", sandboxFlags);
if (!loaded.state) {
for (const f of loaded.failures) log(f);
rmSync(tempRoot, { recursive: true, force: true });
@@ -95,24 +109,13 @@ export async function runMcp(rest: string[]): Promise {
return 1;
}
- // Resolve the sandbox posture once at startup. Tool calls honor the same
- // env-driven Docker selection as `jaiph run`: the workspace is isolated by
- // default via a point-in-time snapshot. Inplace is an explicit opt-in via
- // JAIPH_INPLACE=1.
- let dockerConfig: ReturnType["dockerConfig"];
+ // Resolve the sandbox posture once at startup (flags + env, `jaiph run`
+ // semantics: isolated snapshot by default, inplace/unsafe as explicit
+ // opt-ins). Every tool call applies this posture verbatim.
+ let posture: StartupPosture;
try {
- const posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
- dockerConfig = posture.dockerConfig;
- if (dockerConfig.enabled) {
- if (posture.sandboxMode === "inplace") {
- log(
- `jaiph mcp: tool calls run in a Docker sandbox in-place on ${workspaceRoot} ` +
- "(JAIPH_INPLACE=1 opt-in: effects land live on the workspace).",
- );
- } else {
- log(`jaiph mcp: tool calls run in a Docker sandbox (${posture.sandboxMode} mode; workspace isolated).`);
- }
- }
+ posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
+ logStartupPosture("jaiph mcp", "tool calls", posture, workspaceRoot, log);
} catch (err) {
log(err instanceof Error ? err.message : String(err));
rmSync(tempRoot, { recursive: true, force: true });
@@ -128,7 +131,7 @@ export async function runMcp(rest: string[]): Promise {
const lease = generations.acquire();
return callWorkflow(
lease.state.callEnv,
- dockerConfig,
+ posture,
spec.workflow,
spec.params.map((p) => args[p] ?? ""),
randomUUID(),
@@ -152,7 +155,7 @@ export async function runMcp(rest: string[]): Promise {
reloading = true;
try {
generation += 1;
- const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp");
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph mcp", sandboxFlags);
if (!loaded.state) {
log("jaiph mcp: reload failed; keeping the previous tool set:");
for (const f of loaded.failures) log(` ${f}`);
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index ecdb1eee..d387fddb 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -39,7 +39,11 @@ const RUN_USAGE =
" -y, --yes skip the in-place confirmation prompt (sets JAIPH_INPLACE_YES=1 for this run)\n" +
" -- end of jaiph flags; remaining args go to workflow default\n" +
" -h, --help show this help\n\n" +
- "Note: these flags only affect `jaiph run`; the corresponding env vars also apply to other entry points.\n\n" +
+ "--workspace, --env, --inplace, --unsafe, and --yes are the shared execution-policy flags,\n" +
+ "accepted identically by jaiph serve and jaiph mcp. Precedence: CLI flags > JAIPH_* env vars >\n" +
+ "workflow config metadata > defaults. --inplace and --unsafe conflict (E_FLAG_CONFLICT).\n" +
+ "Consent: jaiph run confirms --inplace / unsafe host-only interactively (--yes auto-confirms;\n" +
+ "required non-TTY); the corresponding env vars also apply to other entry points.\n\n" +
"Examples:\n" +
" jaiph run ./flows/review.jh \"review this diff\"\n" +
" jaiph run --inplace --workspace ./app ./flows/fix.jh\n";
@@ -66,7 +70,7 @@ import {
formatRunningBottomLine,
} from "../run/progress";
import { loadMergedHooks, registerHooksSubscriber } from "../run/hooks";
-import { resolveRuntimeEnv, applySandboxFlags, resolveEnvPairs } from "../run/env";
+import { resolveRuntimeEnv, applySandboxFlags, resolveEnvPairs, isUnsafeHostOnly } from "../run/env";
import { preflightAgentCredentials, collectEntryBackends } from "../run/preflight-credentials";
import { planTrustedEnvs } from "../run/trusted-envs";
import { colorize, formatJaiphRunningBannerLines } from "../run/display";
@@ -89,7 +93,7 @@ export async function runWorkflow(rest: string[]): Promise {
}
let parsed: ReturnType;
try {
- parsed = parseArgs(rest);
+ parsed = parseArgs(rest, "run");
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return 1;
@@ -267,7 +271,7 @@ export async function runWorkflow(rest: string[]): Promise {
emitter.emit("workflow_start", {
event: "workflow_start",
- workflow_id: "",
+ workflow_id: runId,
timestamp: new Date().toISOString(),
run_path: inputAbs,
workspace: workspaceRoot,
@@ -346,27 +350,6 @@ export async function runWorkflow(rest: string[]): Promise {
}
}
-/**
- * True when Docker is off specifically because the user opted into unsafe mode
- * (`JAIPH_UNSAFE=true` / `--unsafe`) while Docker would otherwise be on.
- *
- * "Would otherwise be on" is the key gate: it excludes win32 (forced host-only
- * with its own notice) and an explicit `JAIPH_DOCKER_ENABLED=false` (Docker
- * disabled by config, not by the unsafe opt-in). Only the unsafe-driven case
- * gets the extra host-only confirmation.
- */
-function isUnsafeHostOnly(
- dockerEnabled: boolean,
- env: Record,
-): boolean {
- return (
- !dockerEnabled &&
- process.platform !== "win32" &&
- env.JAIPH_DOCKER_ENABLED === undefined &&
- env.JAIPH_UNSAFE === "true"
- );
-}
-
/**
* Raw mode: transparent passthrough for Docker sandbox.
* Parses and compiles the workflow, spawns the runtime with inherited stdio
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index e4656ef1..3514ee61 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -11,8 +11,10 @@ import {
createGenerationTracker,
createSourceWatcher,
resolveStartupPosture,
+ logStartupPosture,
WATCH_INTERVAL_MS,
type GenerationTracker,
+ type StartupPosture,
} from "../shared/generation";
import { ServeHandler } from "../serve/handler";
import { createHttpServer, listen } from "../serve/server";
@@ -44,7 +46,7 @@ function intEnv(raw: string | undefined, name: string, fallback: number, min: nu
}
const SERVE_USAGE =
- "Usage: jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... \n\n" +
+ "Usage: jaiph serve [--host ] [--port ] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... \n\n" +
"Serve the file's workflows as an HTTP API with a generated OpenAPI 3.1 document\n" +
"and an embedded Swagger UI. Anything that speaks HTTP can invoke tested workflows\n" +
"and inspect their runs.\n\n" +
@@ -70,7 +72,17 @@ const SERVE_USAGE =
" --port listen port (default: 5247)\n" +
" --workspace workspace root for import resolution (default: auto-detect)\n" +
" --env KEY=VALUE define KEY in every run's env (repeatable); --env KEY forwards the host value.\n" +
+ " --inplace Docker sandbox with the host workspace bind-mounted rw for every run (JAIPH_INPLACE=1)\n" +
+ " --unsafe every run executes on the host with no sandbox (JAIPH_UNSAFE=true)\n" +
+ " -y, --yes record auto-consent for the posture (JAIPH_INPLACE_YES=1)\n" +
" -h, --help show this help\n\n" +
+ "Execution policy: --workspace/--env/--inplace/--unsafe/--yes are shared with jaiph run and\n" +
+ "jaiph mcp. Precedence: CLI flags > JAIPH_* env vars > workflow config metadata > defaults.\n" +
+ "--inplace and --unsafe conflict (E_FLAG_CONFLICT, at startup before anything is spawned).\n" +
+ "The effective sandbox posture is resolved and printed once at startup and applied to every\n" +
+ "run; launching the server with the flag or env var is the consent (no interactive prompt).\n" +
+ "Inside a container the container itself is the sandbox (the runtime image bakes\n" +
+ "JAIPH_UNSAFE=true), so host-only execution there is the documented standalone posture.\n\n" +
"Example:\n" +
" JAIPH_SERVE_TOKEN=secret jaiph serve --host 0.0.0.0 ./tools.jh\n";
@@ -87,12 +99,13 @@ export async function runServe(rest: string[]): Promise {
}
let parsed: ReturnType;
try {
- parsed = parseArgs(rest);
+ parsed = parseArgs(rest, "serve");
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return 1;
}
- const { workspace, env, positional, host: hostArg, port: portArg } = parsed;
+ const { workspace, env, positional, host: hostArg, port: portArg, inplace, unsafe, yes } = parsed;
+ const sandboxFlags = { inplace, unsafe, yes };
const input = positional[0];
if (!input) {
process.stderr.write("jaiph serve requires a .jh file path\n");
@@ -175,7 +188,7 @@ export async function runServe(rest: string[]): Promise {
let generation = 0;
let generations: GenerationTracker;
try {
- const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve");
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve", sandboxFlags);
if (!loaded.state) {
for (const f of loaded.failures) log(f);
rmSync(tempRoot, { recursive: true, force: true });
@@ -188,24 +201,12 @@ export async function runServe(rest: string[]): Promise {
return 1;
}
- let dockerConfig: ReturnType["dockerConfig"];
+ let posture: StartupPosture;
let hostRunsRoot: string;
try {
- const posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
- dockerConfig = posture.dockerConfig;
+ posture = resolveStartupPosture(generations.current(), inputAbs, workspaceRoot, log);
hostRunsRoot = posture.hostRunsRoot;
- if (dockerConfig.enabled) {
- if (posture.sandboxMode === "inplace") {
- log(
- `jaiph serve: runs execute in a Docker sandbox in-place on ${workspaceRoot} ` +
- "(JAIPH_INPLACE=1 opt-in: effects land live on the workspace).",
- );
- } else {
- log(`jaiph serve: runs execute in a Docker sandbox (${posture.sandboxMode} mode; workspace isolated).`);
- }
- } else {
- log("jaiph serve: runs execute on the host with no sandbox.");
- }
+ logStartupPosture("jaiph serve", "runs", posture, workspaceRoot, log);
} catch (err) {
log(err instanceof Error ? err.message : String(err));
rmSync(tempRoot, { recursive: true, force: true });
@@ -237,7 +238,7 @@ export async function runServe(rest: string[]): Promise {
const lease = generations.acquire();
const p = callWorkflow(
lease.state.callEnv,
- dockerConfig,
+ posture,
spec.workflow,
spec.params.map((pp) => args[pp] ?? ""),
runId,
@@ -262,7 +263,7 @@ export async function runServe(rest: string[]): Promise {
reloading = true;
try {
generation += 1;
- const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve");
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, generation, extraEnv, log, "jaiph serve", sandboxFlags);
if (!loaded.state) {
log("jaiph serve: reload failed; keeping the previous workflows:");
for (const f of loaded.failures) log(` ${f}`);
diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts
index 91e52a94..c6229de2 100644
--- a/src/cli/exec/call.ts
+++ b/src/cli/exec/call.ts
@@ -4,16 +4,22 @@ import type { ChildProcess } from "node:child_process";
import type { JaiphConfig } from "../../config";
import type { jaiphModule } from "../../types";
import { spawnRunProcess, waitForRunExit, cancelRunProcess } from "../run/lifecycle";
-import { resolveRuntimeEnv } from "../run/env";
+import { resolveRuntimeEnv, applySandboxFlags, type SandboxFlags } from "../run/env";
import { collectEntryBackends } from "../run/preflight-credentials";
-import { parseLogEvent, parseStepEvent } from "../run/events";
+import { parseLogEvent, parseStepEvent, type StepEvent } from "../run/events";
+import {
+ runHooksForEvent,
+ stepStartHookPayload,
+ stepEndHookPayload,
+ type MergedHookConfig,
+} from "../run/hooks";
import {
spawnDockerProcess,
stopDockerContainer,
withDockerExitGuard,
resolveDockerHostRunsRoot,
- selectMcpSandboxMode,
type DockerRunConfig,
+ type SandboxMode,
} from "../../runtime/docker";
import { discoverDockerRunDir, remapContainerPath } from "../shared/errors";
import { exportRunTelemetry } from "../telemetry/otlp";
@@ -76,6 +82,30 @@ export interface WorkflowCallEnvironment {
* choke point either way.
*/
extraEnv: Record;
+ /**
+ * Sandbox flags from the server's CLI surface (`--inplace` / `--unsafe` /
+ * `--yes`), applied to every call's runtime env exactly as `jaiph run`
+ * applies them, so the child observes the same `JAIPH_*` posture vars in
+ * every mode. Conflicts were already rejected at server startup.
+ */
+ sandboxFlags?: SandboxFlags;
+ /**
+ * Merged lifecycle-hook config (`hooks.json`), loaded once per generation.
+ * When present, every call dispatches the same four hook events as
+ * interactive `jaiph run` (`workflow_start`, `step_start`, `step_end`,
+ * `workflow_end`) with the same payload shapes.
+ */
+ hooks?: MergedHookConfig;
+}
+
+/**
+ * Sandbox posture for a workflow server, resolved **once at startup**
+ * (`resolveStartupPosture`) and applied verbatim to every call — a call never
+ * re-derives Docker enablement or the sandbox mode from its own env.
+ */
+export interface ExecutionPosture {
+ dockerConfig: DockerRunConfig;
+ sandboxMode: SandboxMode;
}
/** Output accumulated from a run child's streams while it executes. */
@@ -146,7 +176,7 @@ export function capBytes(text: string, cap: number): string {
*/
export async function callWorkflow(
env: WorkflowCallEnvironment,
- dockerConfig: DockerRunConfig,
+ posture: ExecutionPosture,
workflowSymbol: string,
positionalArgs: string[],
runId: string,
@@ -157,10 +187,38 @@ export async function callWorkflow(
runtimeEnv.JAIPH_SOURCE_ABS = env.inputAbs;
runtimeEnv.JAIPH_RUN_ID = runId;
runtimeEnv.JAIPH_SCRIPTS = env.scriptsDir;
+ // Same env normalization as `jaiph run --inplace/--unsafe/--yes`: the child
+ // observes identical JAIPH_* posture vars in every invocation mode. Never
+ // throws here — a flag/env conflict already failed server startup.
+ applySandboxFlags(runtimeEnv, env.sandboxFlags ?? {});
+
+ const startedAt = Date.now();
+ if (env.hooks) {
+ runHooksForEvent(env.hooks, "workflow_start", {
+ event: "workflow_start",
+ workflow_id: runId,
+ timestamp: new Date().toISOString(),
+ run_path: env.inputAbs,
+ workspace: env.workspaceRoot,
+ });
+ }
- const result = dockerConfig.enabled
- ? await callWorkflowDocker(env, dockerConfig, workflowSymbol, positionalArgs, runtimeEnv, runId, caps, ctx)
+ const result = posture.dockerConfig.enabled
+ ? await callWorkflowDocker(env, posture, workflowSymbol, positionalArgs, runtimeEnv, runId, caps, ctx)
: await callWorkflowHost(env, workflowSymbol, positionalArgs, runtimeEnv, runId, caps, ctx);
+
+ if (env.hooks) {
+ runHooksForEvent(env.hooks, "workflow_end", {
+ event: "workflow_end",
+ workflow_id: runId,
+ status: result.isError ? (result.exitStatus || 1) : 0,
+ elapsed_ms: Date.now() - startedAt,
+ timestamp: new Date().toISOString(),
+ run_path: env.inputAbs,
+ workspace: env.workspaceRoot,
+ run_dir: result.runDir,
+ });
+ }
// One export per call — the shared choke point covering every MCP tool call and
// HTTP `jaiph serve` invocation. Best-effort; never changes the call result.
await exportRunTelemetry({
@@ -173,6 +231,28 @@ export async function callWorkflow(
return result;
}
+/**
+ * Combined per-step-event handler for one call: dispatches the step hooks
+ * (when configured) and forwards the caller's progress callback. Passed to
+ * `attachOutputCollector` by both execution paths so hook dispatch cannot
+ * diverge between host and Docker.
+ */
+function buildStepEventHandler(
+ env: WorkflowCallEnvironment,
+ ctx: WorkflowCallContext | undefined,
+): (ev: StepEvent) => void {
+ return (ev) => {
+ if (env.hooks) {
+ if (ev.type === "STEP_START") {
+ runHooksForEvent(env.hooks, "step_start", stepStartHookPayload(ev, ev.id, env.inputAbs, env.workspaceRoot));
+ } else {
+ runHooksForEvent(env.hooks, "step_end", stepEndHookPayload(ev, ev.id, env.inputAbs, env.workspaceRoot));
+ }
+ }
+ ctx?.onStep?.(ev.kind, ev.name);
+ };
+}
+
/** Host execution — same self-spawn path as `jaiph run --raw`. */
async function callWorkflowHost(
env: WorkflowCallEnvironment,
@@ -196,7 +276,7 @@ async function callWorkflowHost(
env: runtimeEnv,
});
ctx?.onCancelHandle?.(() => cancelRunProcess(child));
- const collector = attachOutputCollector(child, ctx?.onStep, caps);
+ const collector = attachOutputCollector(child, buildStepEventHandler(env, ctx), caps);
const exit = await waitForRunExit(child);
collector.drain();
@@ -213,7 +293,7 @@ async function callWorkflowHost(
*/
async function callWorkflowDocker(
env: WorkflowCallEnvironment,
- dockerConfig: DockerRunConfig,
+ posture: ExecutionPosture,
workflowSymbol: string,
positionalArgs: string[],
runtimeEnv: Record,
@@ -221,10 +301,12 @@ async function callWorkflowDocker(
caps: OutputCaps,
ctx?: WorkflowCallContext,
): Promise {
- const sandboxMode = selectMcpSandboxMode(runtimeEnv);
+ // Posture (Docker enablement + sandbox mode) was resolved once at server
+ // startup; the call applies it verbatim rather than re-deriving from env.
+ const sandboxMode = posture.sandboxMode;
const sandboxRunDir = resolveDockerHostRunsRoot(env.workspaceRoot, runtimeEnv);
const dockerResult = spawnDockerProcess({
- config: dockerConfig,
+ config: posture.dockerConfig,
sourceAbs: env.inputAbs,
workspaceRoot: env.workspaceRoot,
sandboxRunDir,
@@ -242,7 +324,7 @@ async function callWorkflowDocker(
stopDockerContainer(dockerResult.containerName);
cancelRunProcess(dockerResult.child);
});
- const collector = attachOutputCollector(dockerResult.child, ctx?.onStep, caps);
+ const collector = attachOutputCollector(dockerResult.child, buildStepEventHandler(env, ctx), caps);
return withDockerExitGuard(dockerResult, async () => {
const exit = await waitForRunExit(dockerResult.child);
collector.drain();
@@ -264,13 +346,13 @@ async function callWorkflowDocker(
/**
* Attach line-oriented listeners to a run child's stderr/stdout. Parses
* `__JAIPH_EVENT__` log/step lines from stderr (child stdout is captured but
- * never forwarded). `onStep` (when given) fires once per `STEP_START`/`STEP_END`
- * event so the caller can stream progress. `drain()` flushes any trailing
- * partial stderr line.
+ * never forwarded). `onStepEvent` (when given) fires once per parsed
+ * `STEP_START`/`STEP_END` event with the full event so the caller can stream
+ * progress and dispatch hooks. `drain()` flushes any trailing partial stderr line.
*/
export function attachOutputCollector(
child: ChildProcess,
- onStep: ((kind: string, name: string) => void) | undefined,
+ onStepEvent: ((event: StepEvent) => void) | undefined,
caps: OutputCaps,
): { data: CollectedOutput; drain: () => void } {
const data: CollectedOutput = { logs: [], rawStderr: "", rawStdout: "" };
@@ -310,7 +392,7 @@ export function attachOutputCollector(
const detail = stepEvent.err_content.trim() || stepEvent.out_content.trim();
data.failedStep = { name: `${stepEvent.kind} ${stepEvent.name}`.trim(), detail };
}
- onStep?.(stepEvent.kind, stepEvent.name);
+ onStepEvent?.(stepEvent);
return;
}
if (!stderrCut) {
diff --git a/src/cli/run/env.ts b/src/cli/run/env.ts
index 0b18f944..11ddafe3 100644
--- a/src/cli/run/env.ts
+++ b/src/cli/run/env.ts
@@ -73,6 +73,29 @@ export function applySandboxFlags(
}
}
+/**
+ * True when Docker is off specifically because the user opted into unsafe mode
+ * (`JAIPH_UNSAFE=true` / `--unsafe`) while Docker would otherwise be on.
+ *
+ * "Would otherwise be on" is the key gate: it excludes win32 (forced host-only
+ * with its own notice) and an explicit `JAIPH_DOCKER_ENABLED=false` (Docker
+ * disabled by config, not by the unsafe opt-in). Only the unsafe-driven case
+ * gets the extra host-only consent (interactive on `jaiph run`; a startup
+ * notice on `jaiph serve` / `jaiph mcp`, where launching the server with the
+ * flag or env var is the consent).
+ */
+export function isUnsafeHostOnly(
+ dockerEnabled: boolean,
+ env: Record,
+): boolean {
+ return (
+ !dockerEnabled &&
+ process.platform !== "win32" &&
+ env.JAIPH_DOCKER_ENABLED === undefined &&
+ env.JAIPH_UNSAFE === "true"
+ );
+}
+
const LOCKED_ENV_KEYS = [
"JAIPH_AGENT_MODEL",
"JAIPH_AGENT_COMMAND",
diff --git a/src/cli/run/hooks.ts b/src/cli/run/hooks.ts
index e3af0305..0262bb03 100644
--- a/src/cli/run/hooks.ts
+++ b/src/cli/run/hooks.ts
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
import { resolveShell } from "../../runtime/kernel/portability";
import type { HookConfig, HookEventName, HookPayload } from "../../types";
import type { RunEmitter } from "./emitter";
+import type { StepEvent } from "./events";
const HOOKS_FILENAME = "hooks.json";
@@ -166,6 +167,52 @@ export function runHooksForEvent(
}
}
+/**
+ * Build the `step_start` hook payload from a parsed runtime step event. One
+ * builder for every invocation mode (direct `jaiph run`, `jaiph serve` HTTP
+ * runs, `jaiph mcp` tool calls) so the payload contract cannot drift.
+ */
+export function stepStartHookPayload(
+ event: StepEvent,
+ stepId: string,
+ inputAbs: string,
+ workspaceRoot: string,
+): HookPayload {
+ return {
+ event: "step_start",
+ workflow_id: event.run_id,
+ step_id: stepId,
+ step_kind: event.kind,
+ step_name: event.name,
+ timestamp: event.ts || new Date().toISOString(),
+ run_path: inputAbs,
+ workspace: workspaceRoot,
+ };
+}
+
+/** Build the `step_end` hook payload — shared across all three invocation modes. */
+export function stepEndHookPayload(
+ event: StepEvent,
+ stepId: string,
+ inputAbs: string,
+ workspaceRoot: string,
+): HookPayload {
+ return {
+ event: "step_end",
+ workflow_id: event.run_id,
+ step_id: stepId,
+ step_kind: event.kind,
+ step_name: event.name,
+ status: event.status ?? 1,
+ elapsed_ms: event.elapsed_ms ?? 0,
+ timestamp: event.ts || new Date().toISOString(),
+ run_path: inputAbs,
+ workspace: workspaceRoot,
+ out_file: event.out_file || undefined,
+ err_file: event.err_file || undefined,
+ };
+}
+
/** Subscribe to emitter events and invoke hooks for each lifecycle event. */
export function registerHooksSubscriber(
emitter: RunEmitter,
@@ -174,33 +221,11 @@ export function registerHooksSubscriber(
workspaceRoot: string,
): void {
emitter.on("step_start", (data) => {
- runHooksForEvent(config, "step_start", {
- event: "step_start",
- workflow_id: data.event.run_id,
- step_id: data.eventId,
- step_kind: data.event.kind,
- step_name: data.event.name,
- timestamp: data.event.ts || new Date().toISOString(),
- run_path: inputAbs,
- workspace: workspaceRoot,
- });
+ runHooksForEvent(config, "step_start", stepStartHookPayload(data.event, data.eventId, inputAbs, workspaceRoot));
});
emitter.on("step_end", (data) => {
- runHooksForEvent(config, "step_end", {
- event: "step_end",
- workflow_id: data.event.run_id,
- step_id: data.eventId,
- step_kind: data.event.kind,
- step_name: data.event.name,
- status: data.event.status ?? 1,
- elapsed_ms: data.event.elapsed_ms ?? 0,
- timestamp: data.event.ts || new Date().toISOString(),
- run_path: inputAbs,
- workspace: workspaceRoot,
- out_file: data.event.out_file || undefined,
- err_file: data.event.err_file || undefined,
- });
+ runHooksForEvent(config, "step_end", stepEndHookPayload(data.event, data.eventId, inputAbs, workspaceRoot));
});
emitter.on("workflow_start", (payload) => {
diff --git a/src/cli/shared/generation-posture.test.ts b/src/cli/shared/generation-posture.test.ts
new file mode 100644
index 00000000..2ea77008
--- /dev/null
+++ b/src/cli/shared/generation-posture.test.ts
@@ -0,0 +1,145 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadGeneration, resolveStartupPosture, logStartupPosture, type GenerationState } from "./generation";
+import type { SandboxFlags } from "../run/env";
+import { _dockerExec } from "../../runtime/docker";
+
+/**
+ * `resolveStartupPosture` reads the server's process env; these tests pin the
+ * flag → posture mapping, so the sandbox-control keys must not leak in from
+ * the test runner env (`npm test` exports JAIPH_UNSAFE=true globally).
+ */
+const CONTROL_KEYS = ["JAIPH_UNSAFE", "JAIPH_INPLACE", "JAIPH_INPLACE_YES", "JAIPH_DOCKER_ENABLED"] as const;
+
+function withCleanEnv(overrides: Record, fn: () => T): T {
+ const saved: Record = {};
+ for (const key of CONTROL_KEYS) {
+ saved[key] = process.env[key];
+ delete process.env[key];
+ }
+ for (const [key, value] of Object.entries(overrides)) {
+ saved[key] = saved[key] ?? process.env[key];
+ process.env[key] = value;
+ }
+ try {
+ return fn();
+ } finally {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ }
+}
+
+/** Stub every docker CLI call (info / inspect / verify) as succeeding. */
+function withDockerStub(fn: () => T): T {
+ const orig = _dockerExec.run;
+ _dockerExec.run = () => {};
+ try {
+ return fn();
+ } finally {
+ _dockerExec.run = orig;
+ }
+}
+
+function makeGeneration(flags: SandboxFlags): { state: GenerationState; workspaceRoot: string; cleanup: () => void } {
+ const workspaceRoot = mkdtempSync(join(tmpdir(), "jaiph-posture-ws-"));
+ mkdirSync(join(workspaceRoot, ".jaiph"), { recursive: true });
+ const inputAbs = join(workspaceRoot, "tools.jh");
+ writeFileSync(inputAbs, ["# Greets.", "workflow greet() {", ' return "hi"', "}", ""].join("\n"));
+ const tempRoot = mkdtempSync(join(tmpdir(), "jaiph-posture-tmp-"));
+ const loaded = loadGeneration(inputAbs, workspaceRoot, tempRoot, 0, {}, () => {}, "test", flags);
+ assert.ok(loaded.state, `fixture must validate: ${loaded.failures.join("; ")}`);
+ return {
+ state: loaded.state!,
+ workspaceRoot,
+ cleanup: () => {
+ rmSync(workspaceRoot, { recursive: true, force: true });
+ rmSync(tempRoot, { recursive: true, force: true });
+ },
+ };
+}
+
+function postureFor(flags: SandboxFlags, overrides: Record = {}) {
+ const gen = makeGeneration(flags);
+ try {
+ return withCleanEnv(overrides, () =>
+ withDockerStub(() => {
+ const posture = resolveStartupPosture(gen.state, gen.state.callEnv.inputAbs, gen.workspaceRoot, () => {});
+ const lines: string[] = [];
+ logStartupPosture("jaiph serve", "runs", posture, gen.workspaceRoot, (l) => lines.push(l));
+ return { posture, lines, workspaceRoot: gen.workspaceRoot };
+ }),
+ );
+ } finally {
+ gen.cleanup();
+ }
+}
+
+test("startup posture: --inplace keeps Docker on with the inplace sandbox mode, printed once", () => {
+ const { posture, lines, workspaceRoot } = postureFor({ inplace: true });
+ assert.equal(posture.dockerConfig.enabled, true);
+ assert.equal(posture.sandboxMode, "inplace");
+ assert.equal(posture.unsafeHostOnly, false);
+ assert.equal(lines.length, 1);
+ assert.ok(lines[0].includes(`in-place on ${workspaceRoot}`), lines[0]);
+});
+
+test("startup posture: env JAIPH_INPLACE=1 resolves identically to --inplace (env layer parity)", () => {
+ const viaFlag = postureFor({ inplace: true });
+ const viaEnv = postureFor({}, { JAIPH_INPLACE: "1" });
+ assert.equal(viaEnv.posture.dockerConfig.enabled, viaFlag.posture.dockerConfig.enabled);
+ assert.equal(viaEnv.posture.sandboxMode, viaFlag.posture.sandboxMode);
+});
+
+test("startup posture: --unsafe turns Docker off as the unsafe opt-in, printed once", () => {
+ const { posture, lines } = postureFor({ unsafe: true });
+ assert.equal(posture.dockerConfig.enabled, false);
+ assert.equal(posture.unsafeHostOnly, true);
+ assert.equal(lines.length, 1);
+ assert.ok(lines[0].includes("on the host with no sandbox (unsafe opt-in"), lines[0]);
+});
+
+test("startup posture: Docker off by explicit config is not the unsafe opt-in", () => {
+ const { posture, lines } = postureFor({}, { JAIPH_DOCKER_ENABLED: "false" });
+ assert.equal(posture.dockerConfig.enabled, false);
+ assert.equal(posture.unsafeHostOnly, false);
+ assert.equal(lines[0], "jaiph serve: runs execute on the host with no sandbox.");
+});
+
+test("startup posture: default (no flags, no env) is the isolated Docker snapshot", () => {
+ const { posture, lines } = postureFor({});
+ assert.equal(posture.dockerConfig.enabled, true);
+ assert.equal(posture.sandboxMode, "snapshot");
+ assert.ok(lines[0].includes("snapshot mode; workspace isolated"), lines[0]);
+});
+
+test("startup posture: --inplace + JAIPH_UNSAFE=true conflict fails before anything spawns", () => {
+ const gen = makeGeneration({ inplace: true });
+ try {
+ withCleanEnv({ JAIPH_UNSAFE: "true" }, () =>
+ withDockerStub(() => {
+ assert.throws(
+ () => resolveStartupPosture(gen.state, gen.state.callEnv.inputAbs, gen.workspaceRoot, () => {}),
+ /E_FLAG_CONFLICT/,
+ );
+ }),
+ );
+ } finally {
+ gen.cleanup();
+ }
+});
+
+test("loadGeneration threads sandbox flags and the merged hook config into the call environment", () => {
+ const gen = makeGeneration({ unsafe: true, yes: true });
+ try {
+ assert.deepEqual(gen.state.callEnv.sandboxFlags, { unsafe: true, yes: true });
+ assert.ok(gen.state.callEnv.hooks, "hooks config is loaded per generation");
+ assert.deepEqual(gen.state.callEnv.hooks!.workflow_start, [], "empty workspace has no hook commands");
+ } finally {
+ gen.cleanup();
+ }
+});
diff --git a/src/cli/shared/generation.ts b/src/cli/shared/generation.ts
index 5f61fca2..9f87c239 100644
--- a/src/cli/shared/generation.ts
+++ b/src/cli/shared/generation.ts
@@ -13,8 +13,9 @@ import {
type DockerRunConfig,
type SandboxMode,
} from "../../runtime/docker";
-import { resolveRuntimeEnv } from "../run/env";
+import { resolveRuntimeEnv, applySandboxFlags, isUnsafeHostOnly, type SandboxFlags } from "../run/env";
import { preflightAgentCredentials } from "../run/preflight-credentials";
+import { loadMergedHooks } from "../run/hooks";
import { deriveTools, type McpToolSpec } from "../mcp/tools";
import type { WorkflowCallEnvironment } from "../exec/call";
@@ -43,6 +44,7 @@ export function loadGeneration(
extraEnv: Record,
log: (line: string) => void,
label: string,
+ sandboxFlags: SandboxFlags = {},
): { state?: GenerationState; failures: string[] } {
const graph = loadModuleGraph(inputAbs, workspaceRoot);
const diag = collectDiagnostics(graph);
@@ -65,11 +67,15 @@ export function loadGeneration(
const resolvedModuleMetadata = resolveModuleMetadata(mod, process.env);
const effectiveConfig = metadataToConfig(resolvedModuleMetadata);
+ // Hooks reload with the generation, so a hooks.json edit is picked up on the
+ // next source change like every other per-generation input.
+ const hooks = loadMergedHooks(workspaceRoot);
+
return {
state: {
graph,
tools,
- callEnv: { inputAbs, workspaceRoot, mod, effectiveConfig, scriptsDir, graphFile, outDir, extraEnv },
+ callEnv: { inputAbs, workspaceRoot, mod, effectiveConfig, scriptsDir, graphFile, outDir, extraEnv, sandboxFlags, hooks },
},
failures: [],
};
@@ -139,23 +145,35 @@ export function createGenerationTracker(initial: GenerationState): GenerationTra
};
}
+/** Startup sandbox posture of a workflow server, resolved once and applied to every call. */
+export interface StartupPosture {
+ dockerConfig: DockerRunConfig;
+ sandboxMode: SandboxMode;
+ hostRunsRoot: string;
+ /** True when Docker is off *because of* the unsafe opt-in (not config/platform). */
+ unsafeHostOnly: boolean;
+}
+
/**
* Resolve the shared startup sandbox posture for a workflow server (`jaiph mcp`
- * and `jaiph serve`): the env-driven Docker selection (`jaiph run` semantics),
- * a one-time image preparation when Docker is on, and the credential pre-flight
- * (demoted to warnings — the server may outlive a credential fix). Throws when
- * Docker is enabled but unavailable / the image can't be prepared; the caller
- * turns that into an exit-1. Returns the resolved config + sandbox mode so the
- * caller can print its own startup notice.
+ * and `jaiph serve`): sandbox flags normalized into env (`jaiph run` semantics —
+ * a flag/env posture conflict throws `E_FLAG_CONFLICT` here, before anything is
+ * spawned), the env-driven Docker selection, a one-time image preparation when
+ * Docker is on, and the credential pre-flight (demoted to warnings — the server
+ * may outlive a credential fix). Throws when Docker is enabled but unavailable /
+ * the image can't be prepared; the caller turns that into an exit-1. Returns the
+ * resolved posture so the caller can print the startup notice and apply the same
+ * posture to every call.
*/
export function resolveStartupPosture(
state: GenerationState,
inputAbs: string,
workspaceRoot: string,
log: (line: string) => void,
-): { dockerConfig: DockerRunConfig; sandboxMode: SandboxMode; hostRunsRoot: string } {
+): StartupPosture {
const mod = state.graph.modules.get(inputAbs)!.ast;
const startupEnv = resolveRuntimeEnv(state.callEnv.effectiveConfig, workspaceRoot, inputAbs);
+ applySandboxFlags(startupEnv, state.callEnv.sandboxFlags ?? {});
const dockerConfig = resolveDockerConfig(resolveModuleMetadata(mod, process.env)?.runtime, startupEnv);
if (dockerConfig.enabled) {
// Prepare the image once here rather than per call (a cold pull is slow).
@@ -163,6 +181,7 @@ export function resolveStartupPosture(
prepareImage(dockerConfig);
}
const sandboxMode = selectMcpSandboxMode(startupEnv);
+ const unsafeHostOnly = isUnsafeHostOnly(dockerConfig.enabled, startupEnv);
// Credential pre-flight once at startup (warnings only: the server may outlive
// a credential fix, and per-call failures still surface).
const credPreflight = preflightAgentCredentials({
@@ -178,7 +197,39 @@ export function resolveStartupPosture(
const hostRunsRoot = dockerConfig.enabled
? resolveDockerHostRunsRoot(workspaceRoot, startupEnv)
: resolveHostRunsRoot(workspaceRoot, startupEnv);
- return { dockerConfig, sandboxMode, hostRunsRoot };
+ return { dockerConfig, sandboxMode, hostRunsRoot, unsafeHostOnly };
+}
+
+/**
+ * Print the effective sandbox posture once at server startup — the single
+ * notice both `jaiph serve` and `jaiph mcp` emit, so the wording (and the
+ * consent story it states) cannot drift between modes. `noun` names what the
+ * server executes ("runs" for HTTP, "tool calls" for MCP).
+ */
+export function logStartupPosture(
+ label: string,
+ noun: string,
+ posture: StartupPosture,
+ workspaceRoot: string,
+ log: (line: string) => void,
+): void {
+ if (posture.dockerConfig.enabled) {
+ if (posture.sandboxMode === "inplace") {
+ log(
+ `${label}: ${noun} execute in a Docker sandbox in-place on ${workspaceRoot} ` +
+ "(inplace opt-in: effects land live on the workspace).",
+ );
+ } else {
+ log(`${label}: ${noun} execute in a Docker sandbox (${posture.sandboxMode} mode; workspace isolated).`);
+ }
+ } else if (posture.unsafeHostOnly) {
+ log(
+ `${label}: ${noun} execute on the host with no sandbox (unsafe opt-in: ` +
+ "full filesystem and host environment access).",
+ );
+ } else {
+ log(`${label}: ${noun} execute on the host with no sandbox.`);
+ }
}
/** Host runs root: absolute `JAIPH_RUNS_DIR` as-is, relative under the workspace, else `.jaiph/runs`. */
diff --git a/src/cli/shared/usage.test.ts b/src/cli/shared/usage.test.ts
index 041185fb..054dd148 100644
--- a/src/cli/shared/usage.test.ts
+++ b/src/cli/shared/usage.test.ts
@@ -228,6 +228,70 @@ test("parseArgs: --env rejects JAIPH_RUN_WORKFLOW (managed via Docker MCP spawn
assert.throws(() => parseArgs(["--env", "JAIPH_RUN_WORKFLOW=greet", "flow.jh"]), /E_ENV_RESERVED/);
});
+// ---------------------------------------------------------------------------
+// parseArgs: per-command flag scoping — shared execution-policy set everywhere,
+// command-specific flags rejected elsewhere, unknown flags never positionals
+// ---------------------------------------------------------------------------
+
+test("parseArgs: shared execution-policy flags parse identically for run, serve, and mcp", () => {
+ for (const command of ["run", "serve", "mcp"] as const) {
+ const r = parseArgs(
+ ["--workspace", "/tmp/ws", "--inplace", "--yes", "--env", "A=1", "flow.jh"],
+ command,
+ );
+ assert.equal(r.workspace, "/tmp/ws", `${command}: --workspace`);
+ assert.equal(r.inplace, true, `${command}: --inplace`);
+ assert.equal(r.yes, true, `${command}: --yes`);
+ assert.deepEqual(r.env, [{ key: "A", value: "1" }], `${command}: --env`);
+ assert.deepEqual(r.positional, ["flow.jh"], `${command}: positional`);
+ }
+});
+
+test("parseArgs: --unsafe and -y are accepted by serve and mcp", () => {
+ for (const command of ["serve", "mcp"] as const) {
+ const r = parseArgs(["--unsafe", "-y", "flow.jh"], command);
+ assert.equal(r.unsafe, true);
+ assert.equal(r.yes, true);
+ }
+});
+
+test("parseArgs: run rejects serve's transport flags, naming the owning command", () => {
+ assert.throws(() => parseArgs(["--host", "0.0.0.0", "flow.jh"], "run"), /--host is not a jaiph run flag.*jaiph serve/);
+ assert.throws(() => parseArgs(["--port", "8080", "flow.jh"], "run"), /--port is not a jaiph run flag.*jaiph serve/);
+});
+
+test("parseArgs: serve rejects run-only flags as usage errors", () => {
+ assert.throws(() => parseArgs(["--target", "/tmp/out", "flow.jh"], "serve"), /--target is not a jaiph serve flag.*jaiph run/);
+ assert.throws(() => parseArgs(["--raw", "flow.jh"], "serve"), /--raw is not a jaiph serve flag.*jaiph run/);
+});
+
+test("parseArgs: mcp rejects run-only and serve-only flags as usage errors", () => {
+ assert.throws(() => parseArgs(["--raw", "flow.jh"], "mcp"), /--raw is not a jaiph mcp flag.*jaiph run/);
+ assert.throws(() => parseArgs(["--target", "/tmp", "flow.jh"], "mcp"), /--target is not a jaiph mcp flag.*jaiph run/);
+ assert.throws(() => parseArgs(["--host", "::1", "flow.jh"], "mcp"), /--host is not a jaiph mcp flag.*jaiph serve/);
+ assert.throws(() => parseArgs(["--port", "1", "flow.jh"], "mcp"), /--port is not a jaiph mcp flag.*jaiph serve/);
+});
+
+test("parseArgs: an unknown flag is a usage error, not a positional", () => {
+ assert.throws(() => parseArgs(["--bogus", "flow.jh"], "run"), /unknown flag --bogus for jaiph run/);
+ assert.throws(() => parseArgs(["-x", "flow.jh"], "serve"), /unknown flag -x for jaiph serve/);
+ assert.throws(() => parseArgs(["--bogus=1", "flow.jh"], "mcp"), /unknown flag --bogus for jaiph mcp/);
+});
+
+test("parseArgs: jaiph run's unknown-flag error points at -- for dash-leading workflow args", () => {
+ assert.throws(() => parseArgs(["flow.jh", "-v5"], "run"), /Use `--` before workflow arguments/);
+});
+
+test("parseArgs: rejected flags are still fine after -- (workflow args untouched)", () => {
+ const r = parseArgs(["flow.jh", "--", "--host", "--bogus", "-x"], "mcp");
+ assert.deepEqual(r.positional, ["flow.jh", "--host", "--bogus", "-x"]);
+});
+
+test("parseArgs: a bare '-' stays positional", () => {
+ const r = parseArgs(["-", "flow.jh"], "run");
+ assert.deepEqual(r.positional, ["-", "flow.jh"]);
+});
+
// ---------------------------------------------------------------------------
// printUsage: lists the new flags under `jaiph run`
// ---------------------------------------------------------------------------
@@ -272,3 +336,26 @@ test("printUsage: example shows --inplace + --workspace combo", () => {
"examples block has the documented --inplace + --workspace combo",
);
});
+
+test("printUsage: documents the shared execution policy (precedence + consent) once", () => {
+ const cap = captureStdout();
+ try {
+ printUsage();
+ } finally {
+ cap.restore();
+ }
+ const text = cap.text();
+ assert.ok(text.includes("Execution policy (shared by jaiph run, jaiph serve, jaiph mcp):"));
+ assert.ok(
+ text.includes("CLI flags > JAIPH_* env vars > workflow config"),
+ "precedence order is spelled out",
+ );
+ assert.ok(text.includes("E_FLAG_CONFLICT"), "posture conflict rule is documented");
+ const serveSection = text.slice(text.indexOf("jaiph serve:"));
+ const mcpSection = text.slice(text.indexOf("jaiph mcp:"));
+ for (const [label, section] of [["serve", serveSection], ["mcp", mcpSection]] as const) {
+ assert.ok(section.includes("--inplace"), `jaiph ${label} section mentions --inplace`);
+ assert.ok(section.includes("--unsafe"), `jaiph ${label} section mentions --unsafe`);
+ assert.ok(section.includes("--yes"), `jaiph ${label} section mentions --yes`);
+ }
+});
diff --git a/src/cli/shared/usage.ts b/src/cli/shared/usage.ts
index a9b71471..d9316406 100644
--- a/src/cli/shared/usage.ts
+++ b/src/cli/shared/usage.ts
@@ -14,13 +14,27 @@ export function printUsage(): void {
" jaiph use ",
" jaiph format [--check] [--indent ] ",
" jaiph compile [--json] [--workspace ] ...",
- " jaiph mcp [--workspace ] [--env KEY[=VALUE]]... # serve the file's workflows as MCP tools over stdio (alias: jaiph --mcp)",
- " jaiph serve [--host ] [--port ] [--workspace ] [--env KEY[=VALUE]]... # serve workflows as an HTTP API + OpenAPI + Swagger UI",
+ " jaiph mcp [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... # serve the file's workflows as MCP tools over stdio (alias: jaiph --mcp)",
+ " jaiph serve [--host ] [--port ] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... # serve workflows as an HTTP API + OpenAPI + Swagger UI",
"",
"Global options:",
" -h, --help show this usage (jaiph --help) — each subcommand also accepts -h / --help",
" -v, --version show version",
"",
+ "Execution policy (shared by jaiph run, jaiph serve, jaiph mcp):",
+ " --workspace , --env KEY[=VALUE], --inplace, --unsafe, and --yes|-y mean the same",
+ " thing in all three commands. Precedence: CLI flags > JAIPH_* env vars > workflow config",
+ " metadata > built-in defaults (a flag sets its env var for that process, so the env layer",
+ " stays the single source of truth consumed by sandbox resolution). --inplace and --unsafe",
+ " are mutually exclusive (E_FLAG_CONFLICT, before anything is spawned). A flag belonging to",
+ " another command is a usage error, never a silently-ignored option or a positional.",
+ " Consent: jaiph run confirms --inplace and unsafe host-only interactively (--yes|-y or",
+ " JAIPH_INPLACE_YES auto-confirms; non-TTY requires it). jaiph serve / jaiph mcp are",
+ " operator-launched servers: passing the flag (or env var) at startup is the consent —",
+ " the effective sandbox posture is resolved and printed once at startup and applied to",
+ " every call. Inside a container the container itself is the sandbox, so unsafe host-only",
+ " proceeds with a notice (the runtime image bakes JAIPH_UNSAFE=true).",
+ "",
"jaiph run:",
" --target keep emitted script files and run metadata under (default: temp dir, cleaned up)",
" --raw skip banner, progress tree, hooks, and failure footer; inherited stdio for embedding / Docker inner run",
@@ -31,8 +45,9 @@ export function printUsage(): void {
" --env KEY=VALUE define KEY=VALUE in the workflow env (repeatable); --env KEY forwards the host value.",
" In a Docker sandbox this is the per-key consent that crosses the env allowlist verbatim.",
" -- end of jaiph flags; remaining args are passed to workflow default",
- " Note: these flags only affect `jaiph run`; the corresponding env vars (JAIPH_INPLACE,",
- " JAIPH_UNSAFE, JAIPH_INPLACE_YES) also apply to other entry points (e.g. `jaiph test`).",
+ " Note: --inplace/--unsafe/--yes are also accepted by jaiph serve and jaiph mcp (see the",
+ " execution-policy section above); the corresponding env vars (JAIPH_INPLACE, JAIPH_UNSAFE,",
+ " JAIPH_INPLACE_YES) additionally apply to other entry points (e.g. `jaiph test`).",
"",
"jaiph test:",
" With no path, discovers *.test.jh under the workspace root. Extra arguments after an optional",
@@ -60,9 +75,14 @@ export function printUsage(): void {
" Serve the file's workflows as MCP tools over stdio. Exposes `export workflow` declarations",
" if any exist, otherwise all top-level workflows except channel route targets; `default` is",
" exposed only when it is the only workflow, named after the file's basename. Tool descriptions",
- " come from `#` comments directly above each workflow. Calls run on the host (like jaiph run --raw).",
+ " come from `#` comments directly above each workflow.",
" --workspace workspace root for import resolution (default: auto-detect).",
" --env KEY=VALUE define KEY in every tool call's env (repeatable); --env KEY forwards the host value.",
+ " --inplace Docker sandbox with the host workspace bind-mounted rw for every call (JAIPH_INPLACE=1).",
+ " --unsafe every call runs on the host with no sandbox (JAIPH_UNSAFE=true).",
+ " -y, --yes record auto-consent for the posture (JAIPH_INPLACE_YES=1).",
+ " Sandbox posture is resolved and printed once at startup and applied to every call",
+ " (see the shared execution-policy section above).",
"",
"jaiph serve:",
" Serve the file's workflows as an HTTP API with a generated OpenAPI 3.1 document",
@@ -76,6 +96,11 @@ export function printUsage(): void {
" --port listen port (default: 5247)",
" --workspace workspace root for import resolution (default: auto-detect).",
" --env KEY=VALUE define KEY in every run's env (repeatable); --env KEY forwards the host value.",
+ " --inplace Docker sandbox with the host workspace bind-mounted rw for every run (JAIPH_INPLACE=1).",
+ " --unsafe every run executes on the host with no sandbox (JAIPH_UNSAFE=true).",
+ " -y, --yes record auto-consent for the posture (JAIPH_INPLACE_YES=1).",
+ " Sandbox posture is resolved and printed once at startup and applied to every run",
+ " (see the shared execution-policy section above).",
"",
"Examples:",
" jaiph --help",
@@ -179,7 +204,49 @@ export interface ParsedArgs {
positional: string[];
}
-export function parseArgs(args: string[]): ParsedArgs {
+/** Commands that share the execution-policy flag surface. */
+export type CliCommand = "run" | "serve" | "mcp";
+
+/**
+ * Which commands accept each flag. The execution-policy set (`--workspace`,
+ * `--env`, `--inplace`, `--unsafe`, `--yes`/`-y`) is shared by all three
+ * commands; the rest are command-specific (display / launch / transport).
+ * A flag passed to a command outside its row is a usage error, never a
+ * silently-ignored option or a positional.
+ */
+const FLAG_COMMANDS: Record = {
+ "--workspace": ["run", "serve", "mcp"],
+ "--env": ["run", "serve", "mcp"],
+ "--inplace": ["run", "serve", "mcp"],
+ "--unsafe": ["run", "serve", "mcp"],
+ "--yes": ["run", "serve", "mcp"],
+ "-y": ["run", "serve", "mcp"],
+ "--target": ["run"],
+ "--raw": ["run"],
+ "--host": ["serve"],
+ "--port": ["serve"],
+};
+
+/**
+ * Reject a token that looks like a flag (`-…` before `--`) but is not accepted
+ * by this command. Distinguishes "belongs to another command" (named in the
+ * error) from "unknown everywhere", and points `jaiph run` users at `--` for
+ * workflow arguments that begin with `-`.
+ */
+function rejectUnsupportedFlag(name: string, command: CliCommand): never {
+ const owners = FLAG_COMMANDS[name];
+ if (owners) {
+ throw new Error(
+ `${name} is not a jaiph ${command} flag (it belongs to ${owners.map((c) => `jaiph ${c}`).join(", ")}). ` +
+ `Run \`jaiph ${command} --help\` for the supported flags.`,
+ );
+ }
+ const passthroughHint =
+ command === "run" ? " Use \`--\` before workflow arguments that start with \`-\`." : "";
+ throw new Error(`unknown flag ${name} for jaiph ${command}.${passthroughHint}`);
+}
+
+export function parseArgs(args: string[], command: CliCommand = "run"): ParsedArgs {
let target: string | undefined;
let raw: boolean | undefined;
let workspace: string | undefined;
@@ -207,6 +274,12 @@ export function parseArgs(args: string[]): ParsedArgs {
inlineValue = arg.slice(eq + 1);
}
+ // Every `-…` token before `--` must be a flag this command supports.
+ // A bare `-` stays positional (conventional stdin marker).
+ if (arg.startsWith("-") && arg !== "-" && !FLAG_COMMANDS[name]?.includes(command)) {
+ rejectUnsupportedFlag(name, command);
+ }
+
// Value-taking flags: value comes from `=` or the next token.
if (name === "--target" || name === "--workspace") {
let val: string | undefined;
From 01746151716382233d557dba889a512250f28271 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Sat, 25 Jul 2026 08:34:08 +0200
Subject: [PATCH 14/24] Fix: give every run mode complete, bounded telemetry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Standalone `jaiph run --raw` previously bypassed the shared OTLP/Sentry
export hook, and the two exporters were awaited sequentially so two
unreachable backends could hold a completed run — and a server's
execution-concurrency slot — for up to two full timeouts.
`runWorkflowRaw` now fires the shared export hook after the child exits,
gated by `shouldExportRawTelemetry` so a user-invoked raw run exports
exactly like a normal run while the inner raw process of a Docker run
skips it (the host stays the single exporter, so a container run exports
exactly once). The OTLP and Sentry exporters now run concurrently under
one flush budget configurable via `JAIPH_TELEMETRY_FLUSH_MS`. On
long-lived `jaiph serve` / `jaiph mcp` processes the call layer detaches
delivery via `deliverRunTelemetryDetached`, marking the run terminal and
releasing its concurrency slot before best-effort export; detached
failures are tracked in bounded cumulative metrics with warnings capped.
`OTEL_*` / `SENTRY_*` stay operator-side — dropped by the prompt-env
allowlist and excluded from Docker forwarding, now pinned by tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +-
QUEUE.md | 18 --
docs/env-vars.md | 12 +-
docs/observability.md | 28 ++-
integration/otlp-export.test.ts | 241 ++++++++++++++++++++++-
integration/sentry-export.test.ts | 97 +++++++++
src/cli/commands/run.test.ts | 14 +-
src/cli/commands/run.ts | 35 ++++
src/cli/exec/call.ts | 10 +-
src/cli/telemetry/otlp.test.ts | 122 ++++++++++++
src/cli/telemetry/otlp.ts | 155 ++++++++++++---
src/cli/telemetry/sentry.test.ts | 2 +-
src/cli/telemetry/sentry.ts | 48 +++--
src/runtime/docker.test.ts | 35 ++++
src/runtime/docker.ts | 14 ++
src/runtime/kernel/env-allowlist.test.ts | 11 ++
16 files changed, 768 insertions(+), 78 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83749c31..8ccca909 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
- **Serve workflows over HTTP:** run `jaiph serve ./tools.jh` to expose a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so a CI job, a Kubernetes deployment, or any HTTP client can invoke tested workflows and inspect their runs — no MCP client or local jaiph install required. Bearer-token auth, a concurrency cap, per-run output caps, bounded run retention, and the same durable `.jaiph/runs/` records as `jaiph run` are built in, so a long-lived service stays within a bounded memory footprint.
-- **Export traces to an OTLP collector:** every run now exports one OpenTelemetry span tree (workflow → steps → prompts) to any OTLP/HTTP collector — a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog — enabled by the standard `OTEL_EXPORTER_OTLP_ENDPOINT`. Export is host-side and end-of-run (it reads the run's already credential-redacted `run_summary.jsonl`), adds no runtime dependencies, and is never load-bearing: an unreachable or erroring collector produces exactly one stderr warning and leaves the run's exit code, output, and journal untouched.
+- **Export traces to an OTLP collector:** every run mode now exports one OpenTelemetry span tree (workflow → steps → prompts) to any OTLP/HTTP collector — a local `otel-collector`, Grafana Tempo, Honeycomb, Datadog — enabled by the standard `OTEL_EXPORTER_OTLP_ENDPOINT`. A standard `jaiph run`, a standalone `jaiph run --raw`, and workflows invoked through `jaiph mcp` / `jaiph serve` are all covered (a Docker-sandboxed run is still exported exactly once by the host). Export is host-side and end-of-run (it reads the run's already credential-redacted `run_summary.jsonl`), adds no runtime dependencies, and is never load-bearing: the OTLP and Sentry exporters run concurrently under one bounded flush budget (`JAIPH_TELEMETRY_FLUSH_MS`, default 10 s), and on long-lived `jaiph serve` / `jaiph mcp` processes delivery is detached so an unreachable backend can never delay a terminal result or hold an execution slot — an erroring collector produces one (bounded) stderr warning and leaves the run's exit code, output, and journal untouched.
- **Report failed runs to Sentry:** a run that terminates *unsuccessfully* (nonzero exit or a signal) is now pushed to a Sentry error tracker as one error event — enabled by the standard `SENTRY_DSN` — so operators running workflows on a schedule or as a service get alerting and grouping without scraping run directories. The event carries the workflow, the failing step, a credential-redacted output excerpt, and a run-dir pointer, and groups re-occurrences per workflow + failing step. Like trace export it is host-side, end-of-run, adds no runtime dependencies, and is never load-bearing: an unreachable or erroring Sentry produces exactly one stderr warning and leaves the run's exit code and output untouched. Successful runs send nothing.
@@ -12,6 +12,8 @@
## All changes
+- **Fix — give every run mode complete, bounded telemetry behavior:** the telemetry hook covered a standard `jaiph run`, HTTP `jaiph serve` calls, and `jaiph mcp` calls, but a standalone `jaiph run --raw` bypassed it (the docs claimed every run was covered) and the two exporters were **awaited sequentially**, so two unreachable backends could hold a completed run — and, on a server, an occupied execution-concurrency slot — for up to two full timeouts. Four gaps closed. **`--raw` now exports:** `runWorkflowRaw` (`src/cli/commands/run.ts`) fires the shared `exportRunTelemetry` hook after the child exits, gated by the new exported `shouldExportRawTelemetry(env)` so a user-invoked standalone raw run exports exactly like a normal run, while the **inner** raw process of a host-orchestrated Docker run skips it — the outer host process is the single exporter for that run, so a container run is still exported exactly once (no double export). The gate reads the new `DOCKER_SANDBOX_ENV` marker (`JAIPH_DOCKER_SANDBOX`), which `buildDockerArgs` (`src/runtime/docker.ts`) now always sets as `-e …=1` on the container; it is covered by the `JAIPH_DOCKER_*` `--env` reservation and excluded from the forwarding allowlist, so it is never user-settable and never auto-forwarded. **Concurrent under one flush budget:** the shared hook now runs the OTLP-trace and Sentry exporters **concurrently** (`runExporters` in `src/cli/telemetry/otlp.ts`, `Promise.all`) under one total flush budget instead of awaiting them back-to-back, so the worst-case post-run flush is one budget, not two. The budget is configurable via the new `JAIPH_TELEMETRY_FLUSH_MS` (default `10000`; a non-positive or unparseable value falls back to the default — `resolveFlushBudgetMs`) and is threaded into both exporters: `postOtlp` and `reportRunFailureToSentry` now take a `timeoutMs` argument instead of each hard-coding a private 10 s cap. **Detached delivery on long-lived processes:** the shared call layer (`src/cli/exec/call.ts`, covering every MCP `tools/call` and HTTP `jaiph serve` invocation) now calls the new fire-and-forget `deliverRunTelemetryDetached` instead of awaiting `exportRunTelemetry`, so the caller marks the run terminal and releases its execution-concurrency slot **before** best-effort delivery — an unreachable backend can no longer delay a terminal result or hold a slot. Detached failures are tracked in bounded, cumulative counters (`telemetryDeliveryMetrics()` → `{otlpFailures, sentryFailures, warningsEmitted, warningsSuppressed}`) and warnings are capped at `MAX_DELIVERY_WARNINGS` (100) stderr lines before being counted silently, so a server pointed at a dead endpoint cannot grow stderr without bound. The one-shot callers (`jaiph run` completion and standalone `jaiph run --raw`), which must stay alive for the flush, still **await** `exportRunTelemetry`. Each exporter now returns an `ExportOutcome` (`sent` / `skipped` / `failed`) and takes an injectable `warn` sink so the detached path can meter failures. **Telemetry stays operator-side:** `OTEL_*` / `SENTRY_*` are host-only config and must never enter a workflow sandbox — the fail-closed prompt-env allowlist (`scrubPromptEnv`) already drops them and the Docker forwarding allowlist already excludes them; both are now pinned by tests (including the OTLP auth header in `OTEL_EXPORTER_OTLP_HEADERS`), and an explicit `--env` remains the only documented way in. Export failures still never change a workflow's output or exit status. Tests: `src/cli/commands/run.test.ts` (standalone raw exports, docker-inner raw skipped), `src/cli/telemetry/otlp.test.ts` (`resolveFlushBudgetMs` default/override/junk-fallback; two hanging exporters against a black-hole server finish in ~one budget, not two; `deliverRunTelemetryDetached` records both failures in bounded metrics and never throws), `src/runtime/docker.test.ts` (exactly one `JAIPH_DOCKER_SANDBOX=1` marker on the container; `OTEL_*`/`SENTRY_*` dropped by default and forwarded only via explicit `--env`), `src/runtime/kernel/env-allowlist.test.ts` (`scrubPromptEnv` never leaks `OTEL_*`/`SENTRY_*` — including the OTLP auth header — to a prompt backend), and expanded `integration/otlp-export.test.ts` / `integration/sentry-export.test.ts` (standard run, standalone raw run, MCP, and HTTP each produce one OTLP trace and one Sentry event on failure; a Docker-sandboxed run exports exactly once; an unreachable endpoint cannot delay an HTTP/MCP terminal result or occupied slot beyond a small tested bound). Docs: the every-run-mode coverage note and the concurrent-under-one-budget / detached-delivery / bounded-metrics paragraph in [Export traces to an OTLP collector](docs/observability.md), the `JAIPH_TELEMETRY_FLUSH_MS` row and the `JAIPH_DOCKER_SANDBOX` internal-Docker-only row in [Environment variables](docs/env-vars.md), and the telemetry-section note that the one Jaiph-owned knob bounds — but never enables — delivery. (Follow-up to the OTLP + Sentry telemetry work in this release.)
+
- **Feat — one execution-policy contract across `jaiph run`, `jaiph serve`, and `jaiph mcp`:** the three commands now share a single flag surface, precedence order, consent model, and lifecycle-hook contract for the same execution engine, instead of `run` exposing sandbox flags while the long-lived servers required env vars and silently ignored anything they did not destructure (`jaiph serve --unsafe` and `jaiph mcp --inplace` parsed successfully but had **no effect**). **Shared flags:** `--workspace`, repeatable `--env KEY[=VALUE]`, `--inplace`, `--unsafe`, and `--yes`/`-y` mean the same thing in all three commands. `parseArgs` (`src/cli/shared/usage.ts`) now takes the invoking `command` and a `FLAG_COMMANDS` table maps each flag to the commands that accept it; a token that looks like a flag (`-…` before `--`) but belongs to another command is a **usage error that names the owner** (`--host is not a jaiph run flag (it belongs to jaiph serve)`), an unknown flag is rejected rather than swallowed as a positional (with a `jaiph run` hint to use `--` for dash-leading workflow args), and a bare `-` still stays positional. Display/transport options stay command-specific: `--target`/`--raw` remain `jaiph run` only, `--host`/`--port` remain `jaiph serve` only. **Precedence:** one documented order everywhere — CLI flags > `JAIPH_*` env vars > workflow runtime metadata > built-in defaults. A flag sets its `JAIPH_*` variable on the launched env for that process (`applySandboxFlags`, `src/cli/run/env.ts`), so the env layer stays the single source of truth sandbox resolution consumes. Contradictory posture is never resolved by precedence: `--inplace`/`JAIPH_INPLACE` together with `--unsafe`/`JAIPH_UNSAFE` fails with **`E_FLAG_CONFLICT` before anything is spawned**, in all three commands. **Resolve-once posture:** `resolveStartupPosture` (`src/cli/shared/generation.ts`) applies the server's sandbox flags, resolves Docker enablement + sandbox mode + the unsafe-host-only gate **once at startup** into a `StartupPosture`, and the shared `logStartupPosture` prints one notice (wording — and the consent story it states — cannot drift between modes). That posture is threaded to every call as an `ExecutionPosture` (`src/cli/exec/call.ts`): `callWorkflow` applies the same `applySandboxFlags` env normalization as `jaiph run` and `callWorkflowDocker` applies the resolved sandbox mode **verbatim** instead of re-deriving it from each call's env. The documented standalone-container exception is preserved — inside a container the container/pod *is* the sandbox, the runtime image bakes `JAIPH_UNSAFE=true`, and unsafe host-only proceeds with a one-line notice. **Consent:** `jaiph run` confirms `--inplace` and unsafe-host-only interactively (`--yes`/`JAIPH_INPLACE_YES` auto-confirms; required non-TTY); for `jaiph serve` / `jaiph mcp`, launching the server with the flag or env var **is** the consent (no prompt), and `--yes`/`JAIPH_INPLACE_YES` is recorded on every call's env. **One hook contract:** the four lifecycle events (`workflow_start`, `step_start`, `step_end`, `workflow_end`) now fire for direct `jaiph run`, HTTP `jaiph serve` runs, and `jaiph mcp` tool calls alike. The `step_start`/`step_end` payload builders were extracted into `stepStartHookPayload`/`stepEndHookPayload` (`src/cli/run/hooks.ts`) and shared by the run emitter and by `callWorkflow`'s `buildStepEventHandler`, which is passed to `attachOutputCollector` on **both** the host and Docker call paths so hook dispatch cannot diverge; `hooks.json` is loaded per generation (`loadMergedHooks` in `loadGeneration`) and re-read on each source reload. Mode differences are now explicit rather than accidental: `jaiph run --raw` and `jaiph test` are documented no-hook lanes. As part of unifying the contract, `workflow_start` now carries the **run id** in `workflow_id` in every mode (direct runs previously emitted it empty). Tests: `integration/exec-policy.test.ts` (new — a table-driven suite runs the same sandbox/env cases through all three modes and asserts the same effective child env and filesystem isolation, that `serve --unsafe`/`serve --inplace`/`mcp --unsafe`/`mcp --inplace` have tested effects, that conflicting posture fails before spawning, and that all four lifecycle hooks fire with the documented payloads for direct/HTTP/MCP), `src/cli/shared/generation-posture.test.ts` (new — the flag → posture mapping with injected docker seams), `src/cli/shared/usage.test.ts` (per-command flag scoping — shared flags parse identically for run/serve/mcp, another command's flags and unknown flags are usage errors, `--` passthrough and bare `-` untouched, and `printUsage` documents the shared precedence + consent once), and `src/cli/commands/run.test.ts` (`E_FLAG_CONFLICT` for `--inplace --unsafe` and for `--inplace` + `JAIPH_UNSAFE=true`, no docker exec or spawn invoked). Docs: a new [Precedence](docs/env-vars.md#precedence) section and updated `JAIPH_INPLACE` / `JAIPH_INPLACE_YES` / `JAIPH_UNSAFE` / `--env` rows in [Environment variables](docs/env-vars.md), the shared flag tables + usage-error and resolve-once notes for `jaiph run` / `jaiph mcp` / `jaiph serve` in [CLI](docs/cli.md), the precedence layer + `E_FLAG_CONFLICT` note in [Configuration](docs/configuration.md#precedence), the "one contract, three invocation modes" section and the corrected `workflow_start` payload in [Add a hook](docs/hooks.md), the sandbox-flag/precedence notes in [Serve workflows as MCP tools](docs/mcp.md) and [Serve workflows over HTTP](docs/serve.md), the hook-dispatch contract line in [Architecture](docs/architecture.md), and the shared execution-policy section in `printUsage`.
- **Fix — make `jaiph serve` request, event, and artifact I/O scale with bytes transferred:** the HTTP layer still performed avoidable whole-resource work on four paths, all now proportional to the bytes actually moved. **Request bodies settle on abort:** `readBody` (`src/cli/serve/server.ts`) only settled on `end`/`error`, so a client destroyed mid-upload left the promise pending forever with its buffered chunks pinned; it now settles on premature `close`/`error` with `aborted: true`, the connection handler drops the request without invoking the router, and no run slot is ever occupied (the helper is exported for direct unit testing of the settlement contract). **Run-dir resolution is cached:** the events/artifacts endpoints located a still-running run by scanning the whole `.jaiph/runs` tree (`findRunDir`) on *every* SSE poll until finalize set `run_dir`; `ServeHandler.runDirFor` now caches the first resolver hit on the record (`RunRecord.resolvedRunDir`, never part of the public run object, evicted with the record), so one live SSE connection scans at most once no matter how long it follows. **Journals follow by fd + offset:** the SSE follower reread the entire `run_summary.jsonl` from disk on every 250 ms poll; the new `createJournalFollower` (`src/cli/serve/runfiles.ts`) holds an open file descriptor and a byte offset, reads only appended bytes with positional `readSync`, buffers a partial trailing line in memory instead of rereading it, and is closed in the stream's `finally` so a disconnect releases the fd. **Artifacts (and NDJSON journals) stream:** `GET /v1/runs/{id}/artifacts/{path}` loaded the complete file into memory (`readFileSync`) before responding; the handler now returns a `bodyFile` `{path, size}` that the HTTP layer pipes through `node:stream.pipeline` — backpressure pauses the read when the socket is congested, a client disconnect destroys the file stream, `content-length` is set from the stat and the read is pinned to it (`end: size - 1`) so an appending journal can't overrun the advertised length, and a multi-gigabyte artifact costs no server memory. The NDJSON `GET /v1/runs/{id}/events` path streams the journal the same way. An explicit size policy joins the streaming: `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap, since streaming already bounds memory) refuses larger artifacts with `413 E_ARTIFACT_TOO_LARGE`. Tests (each verified to fail against the pre-fix behavior): `src/cli/serve/server.test.ts` (readBody settles promptly on premature close; a destroyed mid-upload request occupies no run slot, never starts a workflow, and the server keeps serving; a 1 MiB artifact round-trips byte-identically through a real socket with `content-length`; a stalled 256 MiB download's file stream is destroyed when the client disconnects), `src/cli/serve/handler.test.ts` (a live SSE connection resolves the run dir with exactly one scan across many polls; the artifact cap returns 413 past the limit while smaller files stream; NDJSON/artifact responses carry `bodyFile` + `content-length`), `src/cli/serve/runfiles.test.ts` (an instrumented-`fs` SSE follow proves each appended journal byte is read exactly once and never before the current offset, and that no poll loads the journal whole; the follower completes a split line from its buffered tail), and `integration/serve-server.test.ts` (a 3 GiB sparse artifact streams to a real client with the serve process's RSS sampled below 1.5 GiB throughout — a buffering server would hold 3 GiB+). Docs: a streaming-download + `JAIPH_SERVE_MAX_ARTIFACT_BYTES` note in the artifact-download step of [Serve workflows over HTTP](docs/serve.md), the `JAIPH_SERVE_MAX_ARTIFACT_BYTES` row in [Environment variables](docs/env-vars.md), the artifact/events endpoint rows plus the `413 E_ARTIFACT_TOO_LARGE` error code and serve-limits bullet in [CLI](docs/cli.md#jaiph-serve), and the `jaiph serve` usage text.
diff --git a/QUEUE.md b/QUEUE.md
index 7d668a5e..b2aaef93 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Give every run mode complete, bounded telemetry behavior #dev-ready
-
-Normal `jaiph run`, HTTP calls, and MCP calls invoke the shared OTLP/Sentry hook, but `jaiph run --raw` bypasses it while the docs claim every run is covered. OTLP and Sentry are awaited sequentially, so unavailable backends can hold a completed run and a service concurrency slot for up to 20 seconds despite being described as non-load-bearing.
-
-Scope:
-
-- Export telemetry for a user-invoked standalone `jaiph run --raw` without double-exporting the inner raw process used by host-orchestrated Docker.
-- Run independent exporters concurrently under one configurable total flush budget.
-- In long-lived HTTP/MCP processes, mark the run terminal and release execution concurrency before best-effort delivery; track delivery failures through bounded logging/metrics.
-- Keep telemetry operator-side: `OTEL_*` and `SENTRY_*` values must not enter workflow sandboxes unless explicitly passed as workflow env.
-
-Acceptance:
-
-- Standard run, standalone raw run, MCP, and HTTP each produce one OTLP trace; each failed mode produces one Sentry event.
-- A Docker-sandboxed run still exports exactly once.
-- Unreachable telemetry endpoints cannot delay an HTTP/MCP terminal result or occupied execution slot beyond a small tested bound.
-- Export failures never change workflow output or exit status.
-
## Expose HTTP API and network MCP from one service process #dev-ready
Today `jaiph serve` is HTTP-only and `jaiph mcp` is stdio-only. The same file can be exposed by two separate processes, but there is no single deployed company service that serves both protocols or a Kubernetes-addressable MCP endpoint.
diff --git a/docs/env-vars.md b/docs/env-vars.md
index ab349759..2fa9560c 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -99,6 +99,7 @@ Contradictory posture is never resolved by precedence: `--inplace` / `JAIPH_INPL
| `JAIPH_SOURCE_ABS` | internal | path | — | — | Absolute path to the entry `.jh` file. Set by the CLI before spawning the runner. |
| `JAIPH_SOURCE_FILE` | internal | string (basename) | entry-file basename | — | Used to name run directories. |
| `JAIPH_STDLIB` | host | path | — | — | Removed from the product. Stripped from the launched env. |
+| `JAIPH_TELEMETRY_FLUSH_MS` | host | int (ms) | `10000` | — | Total flush budget for the post-run telemetry hook. The OTLP-trace and Sentry exporters run concurrently, each bounded by this, so the whole flush cannot exceed it. A non-positive or unparseable value falls back to the default. Best-effort only — never load-bearing on the run. |
| `JAIPH_TEST_MODE` | runtime | bool (exact `"1"`) | `false` | — | Set by `jaiph test` so the runtime skips production-only branches (e.g. file-mode normalization). |
| `JAIPH_UNSAFE` | host | bool (`true` only) | `false` | — | Disable Docker for this run; execute on the host with **no sandbox** (entire filesystem and host environment visible to scripts and agent backends). `--unsafe` is the flag form on `jaiph run`, `jaiph serve`, and `jaiph mcp` (flag wins: it sets this variable for that process). Mutually exclusive with `JAIPH_INPLACE` / `--inplace` (`E_FLAG_CONFLICT`). When this turns Docker off while it would otherwise be on, `jaiph run` requires consent: a TTY warning + `Continue? [y/N]` (default no), or `JAIPH_INPLACE_YES` / `--yes` non-interactively (else `E_UNSAFE_NO_CONFIRM`). `jaiph serve` / `jaiph mcp` never prompt: launching the server with the flag or env var is the consent, and the effective posture is printed once at startup and applied to every call. No prompt when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, Windows host-only override) or on `jaiph run --raw`. The `ghcr.io/jaiphlang/jaiph-runtime` image **bakes `JAIPH_UNSAFE=true`** so it can run standalone (`docker run … jaiph run flow.jh`, or as a k8s pod) — inside the image the container is the sandbox and unsafe host-only proceeds with a one-line notice; see [Deploy](deploy.md). |
| `JAIPH_WORKSPACE` | host, runtime | path | autodetected | — | Workspace root. Inside Docker the host CLI overrides this to `/jaiph/workspace`. |
@@ -107,11 +108,12 @@ Contradictory posture is never resolved by precedence: `--inplace` / `JAIPH_INPL
### Internal Docker-only variables
-One more variable is set by the host CLI on the Docker container but is **not** in the table above: the source-parity harness tracks only names accessed through a literal `env.JAIPH_*` / `process.env["JAIPH_*"]` read in `src/`, and this one escapes that pattern. It is managed entirely by the CLI — never export it by hand.
+Two variables are set by the host CLI on the Docker container but are **not** in the table above: the source-parity harness tracks only names accessed through a literal `env.JAIPH_*` / `process.env["JAIPH_*"]` read in `src/`, and these escape that pattern (read through a computed key). Both are managed entirely by the CLI — never export them by hand.
| Variable | Scope | Role |
|---|---|---|
| `JAIPH_RUN_WORKFLOW` | internal | Root workflow symbol the inner `jaiph run --raw` executes. Set as `-e` only for a non-`default` root (for example an MCP tool call); read back in the container by `runWorkflowRaw` through a computed key. Reserved from `--env` (`E_ENV_RESERVED`). |
+| `JAIPH_DOCKER_SANDBOX` | internal | Marks the inner `jaiph run --raw` as the host-orchestrated sandbox run so it does **not** re-export telemetry — the outer host process exports that run exactly once. Always set as `-e` on the container. Covered by the `JAIPH_DOCKER_*` reservation (`E_ENV_RESERVED`) and never auto-forwarded from the host env. |
## Agent credentials
@@ -135,10 +137,12 @@ For the common "forward this host key" case, the [`trusted_envs`](configuration.
## Telemetry variables
Jaiph reads the standard OpenTelemetry environment variables to export one trace
-per run to an OTLP collector — there are **no `JAIPH_*` variables** for this
-feature. These are consumed **host-side, after the run completes** (they are never
+per run to an OTLP collector — enablement uses **only `OTEL_*` / `SENTRY_*`**, not
+`JAIPH_*`. These are consumed **host-side, after the run completes** (they are never
forwarded into the Docker sandbox), so they are not tracked by the `JAIPH_*`
-source-parity harness above. Export is enabled iff a traces endpoint is set. See
+source-parity harness above. Export is enabled iff a traces endpoint is set. The
+one Jaiph-owned knob is the shared flush budget `JAIPH_TELEMETRY_FLUSH_MS` (in the
+source-parity table above), which bounds — but never enables — delivery. See
[Export traces to an OTLP collector](observability.md).
| Variable | Type | Default | Role |
diff --git a/docs/observability.md b/docs/observability.md
index ff9bdfac..dff4bdfa 100644
--- a/docs/observability.md
+++ b/docs/observability.md
@@ -35,8 +35,12 @@ export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4318/v1/traces"
jaiph run ./flows/review.jh "review this diff"
```
-Every `jaiph run` completion and every workflow invoked through `jaiph mcp` /
-`jaiph serve` then posts exactly one trace.
+Every terminal run posts exactly one trace: an interactive `jaiph run`, a
+standalone `jaiph run --raw`, and every workflow invoked through `jaiph mcp` /
+`jaiph serve`. A Docker-sandboxed run is exported once by the host process that
+launched the container — the inner `jaiph run --raw` inside the container is
+marked as the sandbox run and never re-exports (no `OTEL_*`/`SENTRY_*` crosses
+the boundary anyway), so a container run is not double-counted.
### A local collector
@@ -93,6 +97,15 @@ Telemetry never affects a run. An unreachable or erroring collector produces
are untouched. There are no retries and no queue — a run is minutes long, so
end-of-run batching is the normal OTLP pattern.
+The OTLP-trace and Sentry exporters run **concurrently under one total flush
+budget** (`JAIPH_TELEMETRY_FLUSH_MS`, default 10 s), so the whole post-run flush
+is bounded by that budget rather than the sum of two sequential timeouts. In the
+long-lived `jaiph serve` / `jaiph mcp` processes delivery is **detached**: the
+run is marked terminal and its execution-concurrency slot released *before*
+best-effort delivery, so an unreachable backend can never delay a terminal
+result or hold a slot. Detached delivery failures are counted as bounded
+metrics with capped stderr warnings.
+
Only OTLP/HTTP with a JSON payload is spoken. If `OTEL_EXPORTER_OTLP_PROTOCOL` is
set to anything other than `http/json` (for example `grpc`), Jaiph warns and skips
the export rather than mis-speak a protocol.
@@ -116,8 +129,10 @@ jaiph run ./deploy.jh
```
The same host-side, end-of-run choke point that exports traces fires the report —
-so `jaiph run` completions and workflows invoked through `jaiph mcp` / `jaiph serve`
-are all covered. A malformed DSN produces exactly one stderr warning and no send.
+so `jaiph run` completions (including standalone `jaiph run --raw`) and workflows
+invoked through `jaiph mcp` / `jaiph serve` are all covered, each producing one
+Sentry event on failure. A malformed DSN produces exactly one stderr warning and
+no send.
### What the event carries
@@ -139,8 +154,9 @@ credential-redacted), never from the raw `.out` / `.err` captures:
### Failure is never load-bearing
Like trace export, a Sentry report never affects a run. An unreachable or erroring
-Sentry, a malformed DSN, or a timeout (10 s) produces **exactly one** stderr warning
-line; the run's exit code, output, and journal are untouched. There are no retries.
+Sentry, a malformed DSN, or a timeout (the shared `JAIPH_TELEMETRY_FLUSH_MS`
+budget, default 10 s) produces **exactly one** stderr warning line; the run's exit
+code, output, and journal are untouched. There are no retries.
## Related
diff --git a/integration/otlp-export.test.ts b/integration/otlp-export.test.ts
index 1d054968..8eef5f6a 100644
--- a/integration/otlp-export.test.ts
+++ b/integration/otlp-export.test.ts
@@ -46,6 +46,19 @@ function startCollector(status = 200): Promise {
});
}
+/** Poll until the collector has received at least `n` requests (or time out). */
+function waitForCollector(collector: FakeCollector, n: number, label: string, timeoutMs = 5_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve, reject) => {
+ const tick = (): void => {
+ if (collector.requests.length >= n) return resolve();
+ if (Date.now() > deadline) return reject(new Error(`timeout waiting for ${n} collector request(s): ${label}`));
+ setTimeout(tick, 25);
+ };
+ tick();
+ });
+}
+
/** Bind then immediately release a port so a request to it is refused. */
function reservedClosedPort(): Promise {
return new Promise((resolve) => {
@@ -57,6 +70,71 @@ function reservedClosedPort(): Promise {
});
}
+/**
+ * A "black-hole" endpoint: accepts the connection and reads the request but
+ * never responds, so an *awaited* export would hang until the flush budget. It
+ * lets a timing assertion prove delivery is detached (terminal result returns
+ * without waiting on the hanging POST). All sockets are destroyed on close.
+ */
+function startBlackHole(): Promise<{ port: number; close: () => Promise }> {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server: Server = createServer((req) => {
+ req.resume(); // drain the body; never call res.end()
+ });
+ server.on("connection", (s) => {
+ sockets.add(s);
+ s.on("close", () => sockets.delete(s));
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ resolve({
+ port,
+ close: () =>
+ new Promise((r) => {
+ for (const s of sockets) s.destroy();
+ server.close(() => r());
+ }),
+ });
+ });
+ });
+}
+
+interface ServeProc {
+ baseUrl: string;
+ close: () => Promise;
+}
+
+/** Spawn `jaiph serve --port 0`, resolving once it logs its bound listen URL. */
+function startServe(fixture: string, cwd: string, env: NodeJS.ProcessEnv): Promise {
+ const child = spawn("node", [CLI_PATH, "serve", "--port", "0", fixture], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
+ let stderrBuf = "";
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`serve did not start\nstderr:\n${stderrBuf}`)), 20_000);
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk: string) => {
+ stderrBuf += chunk;
+ const m = stderrBuf.match(/listening on (http:\/\/[^ ]+)/);
+ if (m) {
+ clearTimeout(timer);
+ resolve({
+ baseUrl: m[1],
+ close: () =>
+ new Promise((res) => {
+ child.on("exit", () => res());
+ child.kill("SIGTERM");
+ setTimeout(() => child.kill("SIGKILL"), 8_000).unref();
+ }),
+ });
+ }
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timer);
+ reject(new Error(`serve exited early (code ${code})\nstderr:\n${stderrBuf}`));
+ });
+ });
+}
+
/**
* Run the CLI asynchronously so the test process's event loop keeps running —
* the in-process fake collector must be able to accept the export connection
@@ -144,6 +222,34 @@ test("jaiph run: with OTLP env, exactly one well-formed POST reaches /v1/traces"
}
});
+test("jaiph run --raw: standalone raw run exports exactly one trace", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-raw-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, STEP_FIXTURE);
+ const result = await runCli(["run", "--raw", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collector.port}`,
+ },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ // A one-shot raw run awaits the export before exit, so the POST has landed.
+ assert.equal(collector.requests.length, 1, "standalone --raw exports exactly one trace");
+ assert.equal(collector.requests[0].url, "/v1/traces");
+ const payload = JSON.parse(collector.requests[0].body) as {
+ resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }>;
+ };
+ const spans = payload.resourceSpans[0].scopeSpans[0].spans;
+ assert.ok(spans.some((s) => s.name === "workflow default"), "root span present in raw export");
+ } finally {
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("jaiph run: with no OTLP env, nothing is exported", async () => {
const collector = await startCollector(200);
const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-none-"));
@@ -306,8 +412,10 @@ test("MCP tools/call triggers exactly one export per call via the shared call la
const call = await waitFor((m) => m.id === 1, "tools/call");
assert.equal((call.result as { isError: boolean }).isError, false);
- // The export is awaited inside the shared call layer before the response is
- // sent, so by now exactly one POST has landed for this one call.
+ // Delivery is detached: the tool response is sent (and the concurrency slot
+ // released) before best-effort export, so the POST lands shortly *after* the
+ // response rather than blocking it. Poll for the single request.
+ await waitForCollector(collector, 1, "one export per tools/call");
assert.equal(collector.requests.length, 1, "exactly one export per tools/call");
assert.equal(collector.requests[0].url, "/v1/traces");
const payload = JSON.parse(collector.requests[0].body) as {
@@ -325,3 +433,132 @@ test("MCP tools/call triggers exactly one export per call via the shared call la
rmSync(root, { recursive: true, force: true });
}
});
+
+test("MCP tools/call: an unreachable collector cannot delay the tool response", async () => {
+ const hole = await startBlackHole();
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-mcp-hole-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, ["# Greets.", "workflow greet(name) {", ' return "hi ${name}"', "}", ""].join("\n"));
+ const child = spawn("node", [CLI_PATH, "mcp", jh], {
+ cwd: root,
+ env: {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${hole.port}`,
+ // Budget >> the asserted bound: an awaited export would block ~8 s.
+ JAIPH_TELEMETRY_FLUSH_MS: "8000",
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ const messages: McpMessage[] = [];
+ let buf = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ buf += chunk;
+ let idx = buf.indexOf("\n");
+ while (idx !== -1) {
+ const line = buf.slice(0, idx);
+ buf = buf.slice(idx + 1);
+ if (line.length > 0) messages.push(JSON.parse(line) as McpMessage);
+ idx = buf.indexOf("\n");
+ }
+ });
+ const send = (m: Record): void => void child.stdin.write(`${JSON.stringify(m)}\n`);
+ const waitFor = (pred: (m: McpMessage) => boolean, label: string): Promise =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`timeout: ${label}`)), 6_000);
+ const tick = (): void => {
+ const found = messages.find(pred);
+ if (found) return clearTimeout(timer), resolve(found);
+ setTimeout(tick, 25);
+ };
+ tick();
+ });
+
+ try {
+ send({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "1" } } });
+ await waitFor((m) => m.id === 0, "initialize");
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
+
+ const started = Date.now();
+ send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "greet", arguments: { name: "x" } } });
+ const call = await waitFor((m) => m.id === 1, "tools/call");
+ const elapsed = Date.now() - started;
+ assert.equal((call.result as { isError: boolean }).isError, false);
+ assert.ok(elapsed < 4_000, `tool response must not wait on the hanging export (took ${elapsed}ms)`);
+ } finally {
+ await new Promise((resolve) => {
+ child.on("exit", () => resolve());
+ child.stdin.end();
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
+ });
+ await hole.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// --- HTTP `jaiph serve` shared-call-layer export ---------------------------
+
+const GREET_SERVE_FIXTURE = ["# Greets.", "workflow greet(name) {", ' return "hi ${name}"', "}", ""].join("\n");
+
+test("jaiph serve: an HTTP run exports exactly one trace via the shared call layer", async () => {
+ const collector = await startCollector(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-serve-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, GREET_SERVE_FIXTURE);
+ const serve = await startServe(jh, root, {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collector.port}`,
+ });
+ try {
+ const res = await fetch(`${serve.baseUrl}/v1/workflows/greet/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "x" }),
+ });
+ const run = (await res.json()) as { status: string };
+ assert.equal(run.status, "succeeded", JSON.stringify(run));
+
+ await waitForCollector(collector, 1, "one export per HTTP run");
+ assert.equal(collector.requests.length, 1, "exactly one export per HTTP run");
+ assert.equal(collector.requests[0].url, "/v1/traces");
+ const payload = JSON.parse(collector.requests[0].body) as {
+ resourceSpans: Array<{ resource: { attributes: Array<{ key: string; value: { stringValue?: string } }> } }>;
+ };
+ const wf = payload.resourceSpans[0].resource.attributes.find((a) => a.key === "jaiph.workflow");
+ assert.equal(wf?.value.stringValue, "greet", "the workflow symbol is on the resource");
+ } finally {
+ await serve.close();
+ await collector.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: an unreachable collector cannot delay a terminal ?wait=true result", async () => {
+ const hole = await startBlackHole();
+ const root = mkdtempSync(join(tmpdir(), "jaiph-otlp-serve-hole-"));
+ const jh = join(root, "tools.jh");
+ writeFileSync(jh, GREET_SERVE_FIXTURE);
+ const serve = await startServe(jh, root, {
+ ...baseEnv(join(root, ".jaiph/runs")),
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${hole.port}`,
+ // Budget >> the asserted bound: an awaited export would block the terminal
+ // result and its concurrency slot for ~8 s.
+ JAIPH_TELEMETRY_FLUSH_MS: "8000",
+ });
+ try {
+ const started = Date.now();
+ const res = await fetch(`${serve.baseUrl}/v1/workflows/greet/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "x" }),
+ });
+ const run = (await res.json()) as { status: string };
+ const elapsed = Date.now() - started;
+ assert.equal(run.status, "succeeded", JSON.stringify(run));
+ assert.ok(elapsed < 4_000, `terminal result / slot must release before delivery (took ${elapsed}ms)`);
+ } finally {
+ await serve.close();
+ await hole.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sentry-export.test.ts b/integration/sentry-export.test.ts
index 7523b6c7..aebd7581 100644
--- a/integration/sentry-export.test.ts
+++ b/integration/sentry-export.test.ts
@@ -53,6 +53,54 @@ function reservedClosedPort(): Promise {
});
}
+/** Poll until the fake ingest has received at least `n` requests (or time out). */
+function waitForRequests(sentry: FakeSentry, n: number, label: string, timeoutMs = 5_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve, reject) => {
+ const tick = (): void => {
+ if (sentry.requests.length >= n) return resolve();
+ if (Date.now() > deadline) return reject(new Error(`timeout waiting for ${n} envelope(s): ${label}`));
+ setTimeout(tick, 25);
+ };
+ tick();
+ });
+}
+
+interface ServeProc {
+ baseUrl: string;
+ close: () => Promise;
+}
+
+/** Spawn `jaiph serve --port 0`, resolving once it logs its bound listen URL. */
+function startServe(fixture: string, cwd: string, env: NodeJS.ProcessEnv): Promise {
+ const child = spawn("node", [CLI_PATH, "serve", "--port", "0", fixture], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
+ let stderrBuf = "";
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`serve did not start\nstderr:\n${stderrBuf}`)), 20_000);
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk: string) => {
+ stderrBuf += chunk;
+ const m = stderrBuf.match(/listening on (http:\/\/[^ ]+)/);
+ if (m) {
+ clearTimeout(timer);
+ resolve({
+ baseUrl: m[1],
+ close: () =>
+ new Promise((res) => {
+ child.on("exit", () => res());
+ child.kill("SIGTERM");
+ setTimeout(() => child.kill("SIGKILL"), 8_000).unref();
+ }),
+ });
+ }
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timer);
+ reject(new Error(`serve exited early (code ${code})\nstderr:\n${stderrBuf}`));
+ });
+ });
+}
+
/**
* Run the CLI asynchronously so the test process's event loop keeps running —
* the in-process fake Sentry must accept the envelope connection while the run
@@ -169,6 +217,55 @@ test("jaiph run: a succeeding run with SENTRY_DSN delivers nothing", async () =>
}
});
+test("jaiph run --raw: a failed standalone raw run delivers exactly one Sentry event", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-raw-"));
+ try {
+ const jh = join(root, "app.jh");
+ writeFileSync(jh, FAIL_FIXTURE);
+ const result = await runCli(["run", "--raw", jh], {
+ cwd: root,
+ env: { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(sentry.port) },
+ });
+ assert.notEqual(result.status, 0, "the run itself fails");
+ // A one-shot raw run awaits delivery before exit, so the envelope has landed.
+ assert.equal(sentry.requests.length, 1, "standalone --raw delivers exactly one Sentry event");
+ const event = JSON.parse(sentry.requests[0].body.split("\n")[2]) as { message: { formatted: string } };
+ assert.equal(event.message.formatted, "workflow default failed (exit 3)");
+ } finally {
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph serve: a failed HTTP run delivers exactly one Sentry event via the shared call layer", async () => {
+ const sentry = await startSentry(200);
+ const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-serve-"));
+ const jh = join(root, "tools.jh");
+ // A named workflow (serve requires named tools) whose one step fails.
+ writeFileSync(jh, ['script boom = `echo "step output"; exit 3`', "# Fails on purpose.", "workflow crash() {", " run boom()", "}", ""].join("\n"));
+ const serve = await startServe(jh, root, { ...baseEnv(join(root, ".jaiph/runs")), SENTRY_DSN: dsn(sentry.port) });
+ try {
+ const res = await fetch(`${serve.baseUrl}/v1/workflows/crash/runs?wait=true`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ const run = (await res.json()) as { status: string };
+ assert.equal(run.status, "failed", JSON.stringify(run));
+ // Detached: the terminal result returns before delivery, so poll for the envelope.
+ await waitForRequests(sentry, 1, "one Sentry event per failed HTTP run");
+ assert.equal(sentry.requests.length, 1, "exactly one Sentry event per failed HTTP run");
+ const event = JSON.parse(sentry.requests[0].body.split("\n")[2]) as { message: { formatted: string }; tags: Record };
+ assert.equal(event.message.formatted, "workflow crash failed (exit 3)");
+ assert.equal(event.tags["jaiph.workflow"], "crash");
+ } finally {
+ await serve.close();
+ await sentry.close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("jaiph run: a failed run WITHOUT SENTRY_DSN delivers nothing", async () => {
const sentry = await startSentry(200);
const root = mkdtempSync(join(tmpdir(), "jaiph-sentry-nodsn-"));
diff --git a/src/cli/commands/run.test.ts b/src/cli/commands/run.test.ts
index c7dcfefd..07e9ead0 100644
--- a/src/cli/commands/run.test.ts
+++ b/src/cli/commands/run.test.ts
@@ -3,8 +3,8 @@ import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { runWorkflow } from "./run";
-import { _dockerExec, _dockerSpawn } from "../../runtime/docker";
+import { runWorkflow, shouldExportRawTelemetry } from "./run";
+import { _dockerExec, _dockerSpawn, DOCKER_SANDBOX_ENV } from "../../runtime/docker";
import { UNSAFE_RUN_LOGWARN_MESSAGE } from "../../runtime/docker-inplace";
const MIN_WORKFLOW = `workflow default() {\n log "hi"\n}\n`;
@@ -267,3 +267,13 @@ test("runWorkflow: --workspace bypasses detectWorkspaceRoot — explicit-missing
);
assert.deepEqual(fence.calls(), []);
});
+
+// --- Standalone raw telemetry gate -----------------------------------------
+
+test("shouldExportRawTelemetry: standalone raw exports; docker-inner raw is skipped", () => {
+ // A user-invoked `jaiph run --raw` on the host exports its own telemetry.
+ assert.equal(shouldExportRawTelemetry({}), true);
+ // The inner raw run of a host-orchestrated Docker run is marked and must skip,
+ // so the outer host process is the single exporter (no double export).
+ assert.equal(shouldExportRawTelemetry({ [DOCKER_SANDBOX_ENV]: "1" }), false);
+});
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index d387fddb..b015bbd1 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -62,6 +62,7 @@ import {
resolveDockerHostRunsRoot,
selectSandboxMode,
RUN_WORKFLOW_ENV,
+ DOCKER_SANDBOX_ENV,
} from "../../runtime/docker";
import { confirmInplaceRun, confirmUnsafeRun, UNSAFE_RUN_LOGWARN_MESSAGE } from "../../runtime/docker-inplace";
import {
@@ -396,6 +397,19 @@ async function runWorkflowRaw(
);
const childExit = await waitForRunExit(execResult);
+ // A user-invoked standalone `jaiph run --raw` exports telemetry like a normal
+ // run. The inner raw process of a host-orchestrated Docker run is marked with
+ // DOCKER_SANDBOX_ENV and skips here — the outer host process exports that run
+ // exactly once. Best-effort; never affects the exit status below.
+ if (shouldExportRawTelemetry(process.env)) {
+ await exportRunTelemetry({
+ runDir: readRunDirFromMeta(metaFile),
+ workflow: workflowSymbol,
+ exitStatus: childExit.status,
+ signal: childExit.signal,
+ env: process.env,
+ });
+ }
return childExit.status;
} finally {
if (shouldCleanup) {
@@ -404,6 +418,27 @@ async function runWorkflowRaw(
}
}
+/**
+ * A standalone `jaiph run --raw` exports its own telemetry; the inner raw run of
+ * a host-orchestrated Docker run does not (marked by DOCKER_SANDBOX_ENV), so the
+ * outer host process is the single exporter for that run — no double export.
+ */
+export function shouldExportRawTelemetry(env: NodeJS.ProcessEnv): boolean {
+ return !env[DOCKER_SANDBOX_ENV];
+}
+
+/** Read `run_dir=` from a runner meta file; undefined when absent/unwritten. */
+function readRunDirFromMeta(metaFile: string): string | undefined {
+ if (!existsSync(metaFile)) return undefined;
+ for (const line of readFileSync(metaFile, "utf8").split(/\r?\n/)) {
+ if (line.startsWith("run_dir=")) {
+ const value = line.slice("run_dir=".length).trim();
+ if (value) return value;
+ }
+ }
+ return undefined;
+}
+
function writeWorkflowRootLabel(
mod: ReturnType,
runArgs: string[],
diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts
index c6229de2..e0f8ada3 100644
--- a/src/cli/exec/call.ts
+++ b/src/cli/exec/call.ts
@@ -22,7 +22,7 @@ import {
type SandboxMode,
} from "../../runtime/docker";
import { discoverDockerRunDir, remapContainerPath } from "../shared/errors";
-import { exportRunTelemetry } from "../telemetry/otlp";
+import { deliverRunTelemetryDetached } from "../telemetry/otlp";
import { redactCredentials } from "../../runtime/kernel/redact";
/**
@@ -220,8 +220,12 @@ export async function callWorkflow(
});
}
// One export per call — the shared choke point covering every MCP tool call and
- // HTTP `jaiph serve` invocation. Best-effort; never changes the call result.
- await exportRunTelemetry({
+ // HTTP `jaiph serve` invocation. Detached (fire-and-forget): returning here lets
+ // the caller mark the run terminal and release its execution-concurrency slot
+ // before best-effort delivery, so an unreachable backend can never delay a
+ // terminal result or hold a slot. Delivery failures are tracked as bounded
+ // metrics; never changes the call result.
+ deliverRunTelemetryDetached({
runDir: result.runDir,
workflow: workflowSymbol,
exitStatus: result.exitStatus ?? 0,
diff --git a/src/cli/telemetry/otlp.test.ts b/src/cli/telemetry/otlp.test.ts
index 502b446d..c7658354 100644
--- a/src/cli/telemetry/otlp.test.ts
+++ b/src/cli/telemetry/otlp.test.ts
@@ -1,11 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
+import { createServer, type Server } from "node:http";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { AddressInfo } from "node:net";
+import { join } from "node:path";
import {
runSummaryToOtlp,
resolveOtlpEndpoint,
parseKeyValueList,
+ resolveFlushBudgetMs,
exportRunTelemetry,
+ deliverRunTelemetryDetached,
+ telemetryDeliveryMetrics,
type OtlpMeta,
} from "./otlp";
@@ -264,3 +272,117 @@ test("exportRunTelemetry: no OTLP endpoint → no-op, no output", async () => {
}
assert.equal(captured.length, 0);
});
+
+test("resolveFlushBudgetMs: default when unset, override when positive, fallback on junk", () => {
+ assert.equal(resolveFlushBudgetMs({}), 10_000);
+ assert.equal(resolveFlushBudgetMs({ JAIPH_TELEMETRY_FLUSH_MS: "2500" }), 2500);
+ assert.equal(resolveFlushBudgetMs({ JAIPH_TELEMETRY_FLUSH_MS: "0" }), 10_000, "0 is non-positive → default");
+ assert.equal(resolveFlushBudgetMs({ JAIPH_TELEMETRY_FLUSH_MS: "-5" }), 10_000, "negative → default");
+ assert.equal(resolveFlushBudgetMs({ JAIPH_TELEMETRY_FLUSH_MS: "abc" }), 10_000, "unparseable → default");
+});
+
+/** Accepts a connection and never responds — an awaited POST hangs until the budget. */
+function startBlackHole(): Promise<{ port: number; close: () => Promise }> {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server: Server = createServer((req) => req.resume());
+ server.on("connection", (s) => {
+ sockets.add(s);
+ s.on("close", () => sockets.delete(s));
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const port = (server.address() as AddressInfo).port;
+ resolve({
+ port,
+ close: () =>
+ new Promise((r) => {
+ for (const s of sockets) s.destroy();
+ server.close(() => r());
+ }),
+ });
+ });
+ });
+}
+
+/** Minimal failed-run journal: one workflow + one nonzero step, with a run_id. */
+function writeFailedJournal(dir: string): void {
+ const runId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
+ const lines = [
+ { type: "WORKFLOW_START", workflow: "default", source: "x.jh", ts: "2026-04-21T16:02:00Z", run_id: runId },
+ { type: "STEP_END", func: "boom", kind: "script", name: "boom", ts: "2026-04-21T16:02:01Z", status: 1, id: "R:2", run_id: runId, out_content: "", err_content: "bad\n" },
+ { type: "WORKFLOW_END", workflow: "default", source: "x.jh", ts: "2026-04-21T16:02:02Z", run_id: runId },
+ ];
+ writeFileSync(join(dir, "run_summary.jsonl"), lines.map((l) => JSON.stringify(l)).join("\n"));
+}
+
+test("exportRunTelemetry: OTLP + Sentry run concurrently under one shared flush budget", async () => {
+ const hole = await startBlackHole();
+ const dir = mkdtempSync(join(tmpdir(), "jaiph-flush-"));
+ const original = process.stderr.write.bind(process.stderr);
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = () => true;
+ try {
+ writeFailedJournal(dir);
+ const started = Date.now();
+ await exportRunTelemetry({
+ runDir: dir,
+ workflow: "default",
+ exitStatus: 1,
+ signal: null,
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${hole.port}`,
+ SENTRY_DSN: `http://key@127.0.0.1:${hole.port}/1`,
+ JAIPH_TELEMETRY_FLUSH_MS: "400",
+ },
+ });
+ const elapsed = Date.now() - started;
+ // Both exporters hang; concurrent-under-one-budget finishes near one budget
+ // (~400ms), well under the sequential 2x (~800ms+).
+ assert.ok(elapsed < 750, `concurrent flush should be ~one budget, took ${elapsed}ms`);
+ } finally {
+ (process.stderr as unknown as { write: typeof original }).write = original;
+ await hole.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("deliverRunTelemetryDetached: failures are tracked in bounded metrics, never thrown", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "jaiph-detached-"));
+ const original = process.stderr.write.bind(process.stderr);
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = () => true;
+ try {
+ writeFailedJournal(dir);
+ const before = telemetryDeliveryMetrics();
+ // A refused port (bind then release) fails fast for both exporters.
+ const refused = await new Promise((resolve) => {
+ const s = createServer();
+ s.listen(0, "127.0.0.1", () => {
+ const p = (s.address() as AddressInfo).port;
+ s.close(() => resolve(p));
+ });
+ });
+ deliverRunTelemetryDetached({
+ runDir: dir,
+ workflow: "default",
+ exitStatus: 1,
+ signal: null,
+ env: {
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${refused}`,
+ SENTRY_DSN: `http://key@127.0.0.1:${refused}/1`,
+ JAIPH_TELEMETRY_FLUSH_MS: "1000",
+ },
+ });
+ // Poll the metrics until both failures register (delivery is fire-and-forget).
+ const deadline = Date.now() + 5_000;
+ for (;;) {
+ const m = telemetryDeliveryMetrics();
+ if (m.otlpFailures > before.otlpFailures && m.sentryFailures > before.sentryFailures) break;
+ if (Date.now() > deadline) {
+ assert.fail(`metrics did not register both failures: ${JSON.stringify(m)}`);
+ }
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ } finally {
+ (process.stderr as unknown as { write: typeof original }).write = original;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/src/cli/telemetry/otlp.ts b/src/cli/telemetry/otlp.ts
index 8e43d3e8..1c57c2b9 100644
--- a/src/cli/telemetry/otlp.ts
+++ b/src/cli/telemetry/otlp.ts
@@ -290,17 +290,18 @@ export function parseKeyValueList(raw: string | undefined): Record,
headers: Record,
+ timeoutMs: number,
): Promise {
return postWithTimeout(
endpoint,
JSON.stringify(payload),
{ "content-type": "application/json", ...headers },
- 10_000,
+ timeoutMs,
);
}
@@ -314,44 +315,150 @@ export interface ExportRunTelemetryOptions {
env: NodeJS.ProcessEnv;
}
+/** Per-exporter delivery result — `sent`, `skipped` (disabled/no data), or `failed`. */
+export type ExportOutcome = "sent" | "skipped" | "failed";
+
+/** Default total flush budget (ms) shared by the concurrent exporters. */
+const DEFAULT_FLUSH_BUDGET_MS = 10_000;
+
+/**
+ * Total flush budget shared by both exporters (they run concurrently, each
+ * bounded by this, so the whole post-run flush cannot exceed it). Configurable
+ * via `JAIPH_TELEMETRY_FLUSH_MS`; a non-positive or unparseable value falls back
+ * to the default.
+ */
+export function resolveFlushBudgetMs(env: NodeJS.ProcessEnv): number {
+ const raw = env.JAIPH_TELEMETRY_FLUSH_MS?.trim();
+ if (!raw) return DEFAULT_FLUSH_BUDGET_MS;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_FLUSH_BUDGET_MS;
+}
+
+/** Default warning sink — one stderr line per failure. */
+function stderrWarn(msg: string): void {
+ process.stderr.write(msg);
+}
+
/**
- * Single shared post-run hook, invoked wherever a run reaches terminal state on
- * the host (`jaiph run` completion, the shared MCP/HTTP workflow-call layer) —
- * one choke point for every mode. Dispatches the two independent, best-effort
- * exporters: OTLP traces (every run, when a collector is configured) and a
- * Sentry error report (failed runs only, when `SENTRY_DSN` is set). Neither is
- * load-bearing: a failure in either produces exactly one stderr warning and
- * never affects the run's exit code, output, or journal.
+ * Run both best-effort exporters concurrently under one shared flush budget:
+ * OTLP traces (every run, when a collector is configured) and a Sentry error
+ * report (failed runs only, when `SENTRY_DSN` is set). Concurrency (not the
+ * former sequential await) keeps the worst case at one budget instead of two.
+ * Neither is load-bearing: a failure produces exactly one warning through
+ * `warn` and never affects the run's exit code, output, or journal.
+ */
+function runExporters(
+ opts: ExportRunTelemetryOptions,
+ warn: (msg: string) => void,
+): Promise<{ otlp: ExportOutcome; sentry: ExportOutcome }> {
+ const budgetMs = resolveFlushBudgetMs(opts.env);
+ return Promise.all([
+ exportOtlpTraces(opts, budgetMs, warn),
+ reportRunFailureToSentry(opts, budgetMs, warn),
+ ]).then(([otlp, sentry]) => ({ otlp, sentry }));
+}
+
+/**
+ * Single shared post-run hook for one-shot callers (`jaiph run` completion,
+ * standalone `jaiph run --raw`) that must stay alive for the flush. Awaits both
+ * exporters concurrently under one flush budget; warnings go to stderr.
*/
export async function exportRunTelemetry(opts: ExportRunTelemetryOptions): Promise {
- await exportOtlpTraces(opts);
- await reportRunFailureToSentry(opts);
+ await runExporters(opts, stderrWarn);
+}
+
+/**
+ * Bounded, cumulative delivery metrics for the long-lived (detached) path. A
+ * `jaiph serve` / `jaiph mcp` process can drive many exports at an unreachable
+ * backend; these counters (and the bounded warning cap below) keep that
+ * observable without unbounded stderr growth.
+ */
+export interface TelemetryDeliveryMetrics {
+ otlpFailures: number;
+ sentryFailures: number;
+ warningsEmitted: number;
+ warningsSuppressed: number;
}
-/** OTLP/HTTP trace export half of the post-run hook. Enabled iff a traces endpoint is set. */
-async function exportOtlpTraces(opts: ExportRunTelemetryOptions): Promise {
+const deliveryMetrics: TelemetryDeliveryMetrics = {
+ otlpFailures: 0,
+ sentryFailures: 0,
+ warningsEmitted: 0,
+ warningsSuppressed: 0,
+};
+
+/** Snapshot of the cumulative detached-delivery metrics (copy — never the live object). */
+export function telemetryDeliveryMetrics(): TelemetryDeliveryMetrics {
+ return { ...deliveryMetrics };
+}
+
+/** Max warning lines the detached path prints before suppressing (still counted). */
+const MAX_DELIVERY_WARNINGS = 100;
+
+/** Bounded stderr warner for the detached path: prints up to a cap, then counts silently. */
+function boundedDeliveryWarn(msg: string): void {
+ if (deliveryMetrics.warningsEmitted < MAX_DELIVERY_WARNINGS) {
+ process.stderr.write(msg);
+ deliveryMetrics.warningsEmitted += 1;
+ if (deliveryMetrics.warningsEmitted === MAX_DELIVERY_WARNINGS) {
+ process.stderr.write("jaiph: further telemetry delivery warnings suppressed (counted in metrics)\n");
+ }
+ } else {
+ deliveryMetrics.warningsSuppressed += 1;
+ }
+}
+
+/**
+ * Post-run hook for long-lived HTTP/MCP processes. Fire-and-forget: the caller
+ * has already marked the run terminal and released its execution-concurrency
+ * slot, so telemetry delivery cannot delay a terminal result or hold a slot.
+ * Failures are tracked as bounded metrics (`telemetryDeliveryMetrics`) with
+ * capped stderr warnings, never load-bearing on the run.
+ */
+export function deliverRunTelemetryDetached(opts: ExportRunTelemetryOptions): void {
+ void runExporters(opts, boundedDeliveryWarn)
+ .then(({ otlp, sentry }) => {
+ if (otlp === "failed") deliveryMetrics.otlpFailures += 1;
+ if (sentry === "failed") deliveryMetrics.sentryFailures += 1;
+ })
+ .catch(() => {
+ // runExporters already swallows per-exporter failures; this guards only
+ // against an unexpected internal error so no rejection escapes.
+ });
+}
+
+/**
+ * OTLP/HTTP trace export half of the post-run hook. Enabled iff a traces
+ * endpoint is set. `timeoutMs` bounds the POST; `warn` receives the single
+ * failure/skip line. Returns the delivery outcome for metrics.
+ */
+export async function exportOtlpTraces(
+ opts: ExportRunTelemetryOptions,
+ timeoutMs: number,
+ warn: (msg: string) => void,
+): Promise