diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 289bfaee..9dee6387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,26 @@ jobs: git ls-remote --exit-code https://github.com/jaiphlang/jaiph.git "refs/tags/v${VERSION}" e2e: - name: E2E install and CLI workflow (${{ matrix.os }}) + name: E2E (${{ matrix.os }}, ${{ matrix.label }}) runs-on: ${{ matrix.os }} + env: + # Host/safe split applies on Ubuntu only. macOS runners do not ship Docker the same way — keep host-only there. + # "docker": unset JAIPH_UNSAFE so resolveDockerConfig enables the sandbox (pulls ghcr.io/jaiphlang/jaiph-runtime). + # "host": explicit opt-out, same as a fast local `JAIPH_UNSAFE=true npm run test:e2e`. + JAIPH_UNSAFE: ${{ matrix.jaiph_unsafe }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + include: + - os: ubuntu-latest + label: docker + jaiph_unsafe: "" + - os: ubuntu-latest + label: host + jaiph_unsafe: "true" + - os: macos-latest + label: host + jaiph_unsafe: "true" steps: - name: Checkout uses: actions/checkout@v4 @@ -151,38 +165,106 @@ jobs: id: detect_wsl shell: pwsh run: | + $ciDistro = "jaiph-ci-ubuntu" $distros = @(wsl -l -q | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }) if ($distros.Count -eq 0) { - "distro=" >> $env:GITHUB_OUTPUT - Write-Warning "No WSL distro is available on this runner. Skipping WSL E2E." - exit 0 + Write-Warning "No WSL distro is available on this runner. Importing Ubuntu rootfs for CI." + $archivePath = Join-Path $env:RUNNER_TEMP "ubuntu-base-24.04.tar.gz" + $installPath = Join-Path $env:RUNNER_TEMP "wsl-ubuntu" + $ubuntuBaseUrl = "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/ubuntu-base-24.04-base-amd64.tar.gz" + + if (Test-Path $installPath) { + Remove-Item -Path $installPath -Recurse -Force + } + New-Item -ItemType Directory -Path $installPath -Force | Out-Null + Invoke-WebRequest -Uri $ubuntuBaseUrl -OutFile $archivePath + wsl --import "$ciDistro" "$installPath" "$archivePath" --version 2 + $distros = @("$ciDistro") } $ubuntu = $distros | Where-Object { $_ -match "^Ubuntu" } | Select-Object -First 1 $selected = if ($ubuntu) { $ubuntu } else { $distros[0] } + if (-not $selected) { + Write-Error "Failed to provision a WSL distro for CI." + exit 1 + } "distro=$selected" >> $env:GITHUB_OUTPUT Write-Host "Using WSL distro: $selected" - name: Install Node and run E2E tests in WSL - if: steps.detect_wsl.outputs.distro != '' shell: pwsh run: | $workspace = "${{ github.workspace }}" $distro = "${{ steps.detect_wsl.outputs.distro }}" - wsl -d "$distro" -- bash -lc "set -euo pipefail + $env:JAIPH_WORKSPACE = $workspace + $bashScript = @' + set -euo pipefail export DEBIAN_FRONTEND=noninteractive - sudo apt-get update - sudo apt-get install -y curl ca-certificates + export JAIPH_UNSAFE=true + SUDO= + if [ "$(id -u)" -ne 0 ]; then + SUDO=sudo + fi + $SUDO apt-get update + $SUDO apt-get install -y curl ca-certificates if ! command -v node >/dev/null 2>&1; then - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y nodejs + curl -fsSL https://deb.nodesource.com/setup_20.x | $SUDO -E bash - + $SUDO apt-get install -y nodejs fi - cd \"\$(wslpath '$workspace')\" + cd "$(wslpath "$JAIPH_WORKSPACE")" npm ci npm run test:e2e - " + '@ + wsl -d "$distro" -- bash -lc "$bashScript" - - name: WSL E2E skipped - if: steps.detect_wsl.outputs.distro == '' - shell: pwsh + docker-publish: + name: Publish Docker runtime image + # needs: [test, e2e, docs-local, e2e-wsl] + if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + REGISTRY: ghcr.io + IMAGE_NAME: jaiphlang/jaiph-runtime + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Image tags and jaiph ref + id: meta + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + echo "jaiph_ref=v${VERSION}" >> "$GITHUB_OUTPUT" + else + echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly" >> "$GITHUB_OUTPUT" + echo "jaiph_ref=nightly" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: runtime + file: runtime/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + build-args: | + JAIPH_REPO_REF=${{ steps.meta.outputs.jaiph_ref }} + + - name: Verify pushed image contains jaiph run: | - Write-Host "No WSL distro found on this runner image; skipping WSL E2E." + TAG="$(echo '${{ steps.meta.outputs.tags }}' | cut -d',' -f1)" + docker run --rm --entrypoint sh "${TAG}" -lc "command -v jaiph && jaiph --version" diff --git a/.gitignore b/.gitignore index 1673153f..b15d9eec 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,12 @@ e2e/ensure_fail.sh e2e/current_branch.sh e2e/assign_capture.sh -.obsidian/ \ No newline at end of file +.obsidian/ + +# debug / temp directories (never commit) +docker-*/ +nested-*/ +overlay-*/ +local-*/ +.tmp*/ +QUEUE.md.tmp.* \ No newline at end of file diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh index a85f59e0..22fa919b 100755 --- a/.jaiph/architect_review.jh +++ b/.jaiph/architect_review.jh @@ -102,7 +102,7 @@ workflow review_one_header(header) { const verdict = run first_line_str(packed) const updated_description = run rest_lines_str(packed) const body_file = run jaiph_review_body_file() - run mkdir_p_simple(run, jaiph_tmp_dir()) + run mkdir_p_simple(run jaiph_tmp_dir()) run str_equals(verdict, "dev-ready") catch (err) { run arg_nonempty(updated_description) catch (err) { fail "needs-work requires a non-empty updated_description (questions for the author)." diff --git a/.jaiph/libs/jaiphlang/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh new file mode 100644 index 00000000..5181e212 --- /dev/null +++ b/.jaiph/libs/jaiphlang/artifacts.jh @@ -0,0 +1,36 @@ +#!/usr/bin/env jaiph + +# +# Artifact publishing for Jaiph workflows. +# Copies files from the workspace into ${JAIPH_ARTIFACTS_DIR} so they +# survive sandbox teardown and are readable on the host at +# .jaiph/runs//artifacts/. +# +# Usage: +# import "jaiphlang/artifacts" as artifacts +# +# workflow default() { +# run artifacts.save("./build/output.bin", "build-output.bin") +# run artifacts.save_patch("snapshot.patch") +# } +# +import script "./artifacts.sh" as artifacts + +# Copies the file at `local_path` into the artifacts directory under `name`. +# Returns the absolute path of the saved artifact. +export workflow save(local_path, name) { + return run artifacts("save", local_path, name) +} + +# Runs `git diff` (working tree vs HEAD, excluding .jaiph/) and writes +# the patch to the artifacts directory under `name`. +# Returns the absolute path of the saved patch file. +export workflow save_patch(name) { + return run artifacts("save_patch", name) +} + +# Applies a patch file to the current workspace via `git apply`. +# Useful for replaying artifacts across runs. +export workflow apply_patch(path) { + run artifacts("apply_patch", path) +} diff --git a/.jaiph/libs/jaiphlang/artifacts.sh b/.jaiph/libs/jaiphlang/artifacts.sh new file mode 100755 index 00000000..054a5d25 --- /dev/null +++ b/.jaiph/libs/jaiphlang/artifacts.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Artifacts helper for Jaiph workflows. +# Reads JAIPH_ARTIFACTS_DIR to locate the writable artifacts directory. +# Works identically inside the Docker sandbox and on the host. +# +set -euo pipefail + +ARTIFACTS_DIR="${JAIPH_ARTIFACTS_DIR:?JAIPH_ARTIFACTS_DIR is not set}" + +cmd_save() { + local src="$1" name="$2" + if [[ ! -f "${src}" ]]; then + printf 'artifacts save: file not found: %s\n' "${src}" >&2 + exit 1 + fi + local dest="${ARTIFACTS_DIR}/${name}" + mkdir -p "$(dirname "${dest}")" + cp -- "${src}" "${dest}" + printf '%s' "${dest}" +} + +cmd_save_patch() { + local name="$1" + local dest="${ARTIFACTS_DIR}/${name}" + mkdir -p "$(dirname "${dest}")" + # Exclude .jaiph/ from the produced patch — the runtime writes its own + # state under .jaiph/ and including it would clobber state on apply. + local diff_out + diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)" + if [[ -z "${diff_out}" ]]; then + # Also check for untracked files (intent-to-add) + git add -N . -- ':!.jaiph/' 2>/dev/null || true + diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)" + # Reset intent-to-add to avoid side effects + git reset HEAD -- . 2>/dev/null || true + fi + if [[ -n "${diff_out}" ]]; then + printf '%s\n' "${diff_out}" > "${dest}" + else + # Empty/clean workspace — create empty file + : > "${dest}" + fi + printf '%s' "${dest}" +} + +cmd_apply_patch() { + local patch_path="$1" + if [[ ! -f "${patch_path}" ]]; then + printf 'artifacts apply_patch: patch file not found: %s\n' "${patch_path}" >&2 + exit 1 + fi + if [[ ! -s "${patch_path}" ]]; then + printf 'artifacts apply_patch: patch file is empty: %s\n' "${patch_path}" >&2 + exit 1 + fi + git apply "${patch_path}" +} + +# -- dispatch ---------------------------------------------------------------- +cmd="${1:-}" +shift || true + +case "${cmd}" in + save) cmd_save "$@" ;; + save_patch) cmd_save_patch "$@" ;; + apply_patch) cmd_apply_patch "$@" ;; + *) + printf 'Usage: artifacts [args...]\n' >&2 + exit 1 + ;; +esac diff --git a/CHANGELOG.md b/CHANGELOG.md index eda20425..0360dbeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Unreleased +## Summary + +- **Language / runtime:** Adds `Handle` for `run async`, repair-and-retry `recover` loops on `run` steps, explicit nested managed calls in arguments, and optional `module.*` fields in `config`. +- **Docker / artifacts:** Read-only workspace with a COW sandbox layer and persistence through the run directory. Docker is on by default whenever `JAIPH_UNSAFE` is not set to `true` (including CI). Tighter capabilities plus mount and env denylists. **Breaking:** images must already include `jaiph` (default `ghcr.io/jaiphlang/jaiph-runtime`), with official builds published to GHCR. New `artifacts.jh` library and `JAIPH_ARTIFACTS_DIR` for pulling files and patches out of the sandbox. +- **CLI / polish:** Clearer quoting in the progress tree and prompts, removal of dead isolation-export paths and stray repo debug junk, and a PTY-based E2E test for the async progress tree. + +## All changes + +- **Fix — CLI/Runtime:** Clean up double-quote rendering in step titles and log output — Parameter values displayed in the progress tree (e.g. `message="Found 3 issues in auth module"`) no longer produce backslash-escaped `\"` sequences or ambiguous nested `""` pairs. The fix touches three layers: `formatNamedParamsForDisplay` and `formatParamsForDisplay` in `src/cli/commands/format-params.ts` no longer escape inner double quotes with backslash (the surrounding `key="value"` delimiters are structural, not shell-safe); `formatStartLine` in `src/cli/run/display.ts` applies the same change for prompt previews; and `node-workflow-runtime.ts` strips outer quotes from interpolated channel-send payloads via `stripOuterQuotes` so messages like `"Found 3 issues"` are stored as `Found 3 issues` rather than carrying literal quote wrappers through dispatch. Regression tests added: `format-params-display.test.ts` asserts no `\"` in formatted output for payloads containing inner quotes; `display.test.ts` verifies prompt previews pass through quotes without escaping. E2E golden output for `agent_inbox.jh` updated to match. +- **Cleanup — Runtime:** Remove dead per-call-isolated leftovers from Docker runtime — Deleted `exportWorkspacePatch` and `findRunArtifacts` from `src/runtime/docker.ts`, `exportPatchIfDocker` from `src/runtime/kernel/node-workflow-runtime.ts`, the `findRunArtifacts` call in `src/cli/commands/run.ts`, and ~150 LoC of dead tests in `src/runtime/docker.test.ts`. These functions were written for the abandoned per-call `isolated` keyword and have been fully replaced by the `artifacts.jh` library (`artifacts.save_patch()` for workspace patches, `JAIPH_ARTIFACTS_DIR` for artifact discovery). The automatic `workspace.patch` export during Docker teardown is removed — workflows that need a patch now request one explicitly via the artifacts library. Docs updated (`docs/sandboxing.md`, `docs/architecture.md`, `docs/artifacts.md`). +- **Cleanup — Repo:** Delete top-level debug cruft and harden `.gitignore` — Removed 22+ leftover debug directories at the repo root (`docker-nested-arg.*`, `docker-nested-clean.*`, `overlay-warn.*`, `nested-run-arg.*`, `local-nested-arg.*`, `overlay-manual.*`, `docker-live-debug.*`, and similar) from an abandoned per-call isolation experiment. Also deleted stale tracked files: `safe_name`, `QUEUE.md.tmp.4951`, and empty top-level `lib/` and `run/` directories — none had live consumers in the source tree. Added `.gitignore` patterns (`docker-*/`, `nested-*/`, `overlay-*/`, `local-*/`, `.tmp*/`, `QUEUE.md.tmp.*`) under a `# debug / temp directories (never commit)` section so these cannot return without a deliberate `git add -f` override. No code changes; filesystem hygiene only. +- **Test — E2E/Runtime:** PTY-based TTY test for `run async` progress rendering — New E2E test (`e2e/tests/131_tty_async_progress.sh`) exercises the live progress tree rendering path for `run async` workflows under a real PTY. The test spawns `jaiph run` with a workflow that fans out two concurrent async branches (`run async branch_a()`, `run async branch_b()`), each emitting deterministic progress events over time (log + script steps with sleep). A Python `pty.openpty()` harness captures the raw PTY stream and asserts: (1) each branch's progress events appear under the correct subscript-numbered node (₁, ₂) in the tree, (2) the final frame shows both branches completed with their resolved `Handle` return values (`result-a`, `result-b`), (3) no orphaned ANSI escape sequences survive after CSI stripping, and (4) a `RUNNING` frame was observed during live rendering. The test uses only deterministic steps (no `prompt claude` or external dependencies) and `assert_contains` checks with order-insensitive matching to tolerate async interleaving. This closes a regression-coverage gap — the existing `81_tty_progress_tree.sh` covers synchronous workflows but not the async handle/deferred-resolution render path. Docs updated (`docs/testing.md`, `docs/spec-async-handles.md`). +- **Feature — Runtime/Library:** Artifacts — runtime mount and `artifacts.jh` library for publishing files out of the sandbox — Workflows can now publish files from inside the Docker sandbox (or host workspace) to a host-readable location at `.jaiph/runs//artifacts/`. The feature is split across two layers. **Runtime layer:** the `NodeWorkflowRuntime` creates the `artifacts/` subdirectory under the run directory before the first workflow step and exposes its path via `JAIPH_ARTIFACTS_DIR` (resolves to `/jaiph/run/artifacts` inside the Docker sandbox, `/artifacts` on the host). The existing `/jaiph/run` mount in Docker mode already maps this directory to the host — no new mount is needed. **Library layer:** a new built-in library `.jaiph/libs/jaiphlang/artifacts.jh` (paired with `artifacts.sh`) provides three `export workflow` entries: `save(local_path, name)` copies a file into the artifacts directory; `save_patch(name)` runs `git diff` (excluding `.jaiph/`) and writes the patch; `apply_patch(path)` applies a patch via `git apply`. The library mirrors the existing `queue.jh` / `queue.py` pattern — `import script "./artifacts.sh" as artifacts` with dispatch by subcommand. The `.jaiph/` exclusion in `save_patch` prevents clobbering runtime state when a patch is applied. `JAIPH_ARTIFACTS_DIR` is cleaned from inherited env in `resolveRuntimeEnv` to prevent leaking across nested runs. Runtime unit tests verify `JAIPH_ARTIFACTS_DIR` is set, writable, and exists before workflow execution. E2E test (`129_artifacts_lib.sh`) exercises `save`, `save_patch`, `apply_patch`, clean-workspace patch, and invalid-patch failure. Implementation: `node-workflow-runtime.ts` (artifacts dir creation, env var), `env.ts` (env cleanup), `.jaiph/libs/jaiphlang/artifacts.jh` and `artifacts.sh` (library). Docs updated (`docs/libraries.md`, `docs/artifacts.md`, `docs/configuration.md`, `docs/index.html`). +- **Feature — Language/Runtime:** `Handle` value model for `run async` — `run async ref(args)` now returns a first-class `Handle` value instead of being a fire-and-forget statement. `T` is the same return type the function would have under a synchronous `run`. Capture is supported: `const h = run async ref()`. The handle resolves to the eventual return value on first non-passthrough read (string interpolation, passing as argument to `run`, comparison, conditional branching, match subject, channel send). Passthrough operations (initial capture into `const`, re-assignment) do not force resolution. Once resolved, the handle is replaced in-place by the resolved string value; subsequent reads return the cached value. Workflow exit implicitly joins all remaining unresolved handles created in that scope — this is not an error and preserves backward compatibility. `recover` composition works with `run async`: `run async foo() recover(err) { … }` — the async branch retries using the same retry-limit semantics as non-async `recover` (default 10, configurable via `run.recover_limit`). `catch` also works for single-shot recovery. The parser accepts `recover(err) { … }` and `catch(err) { … }` after `run async ref(args)` (the previous attempt silently rejected this with a "trailing content" error). There is no fire-and-forget mode — every `run async` creates a handle tracked by the runtime. No explicit `await` keyword — resolution is implicit on first read or at workflow exit. The docs-site Jaiph syntax highlighter (`docs/assets/js/main.js`) recognizes `async` as a keyword. Implementation: `Handle` registry in `NodeWorkflowRuntime` (`createHandle`, `resolveHandleResult`, `resolveHandleVar`, `resolveHandlesInInput`), `async` flag on `run_capture` const RHS in `src/types.ts`, async capture parsing in `src/parse/const-rhs.ts`, `recover`/`catch` parsing for `run async` in `src/parse/workflows.ts`, formatter round-trip in `src/format/emit.ts`. Spec: `docs/spec-async-handles.md`. Parser, formatter, runtime, and E2E tests added. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/jaiph-skill.md`, `docs/index.html`). +- **Feature — Language/Runtime:** `recover` loop semantics for `run` steps — `recover` is a new first-class repair-and-retry primitive for `run` steps, distinct from `catch`. Syntax: `run ref() recover(err) { … }`. On failure, the binding receives merged stdout+stderr, the repair body executes, and the target is retried automatically. The loop stops when the target succeeds or the retry limit is exhausted. The default retry limit is 10; override per-module with `run.recover_limit` in a `config` block. `catch` remains unchanged (one-shot recovery). `recover` and `catch` are mutually exclusive on the same step. Supported for non-isolated, non-async `run` in workflows only. The docs-site syntax highlighter (`docs/assets/js/main.js`) recognizes `recover` as a keyword. Implementation: `recoverLoop` field on `WorkflowStepDef` in `src/types.ts`, `parseRunRecoverStep` in `src/parse/steps.ts`, retry loop in `NodeWorkflowRuntime`, `run.recover_limit` config key in `src/parse/metadata.ts`, formatter round-trip in `src/format/emit.ts`, validation in `src/transpile/validate.ts`. Parser, formatter, validation, runtime, and E2E tests added. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/configuration.md`, `docs/jaiph-skill.md`, `docs/index.html`). +- **Feature — Docker:** Workspace immutability contract — Docker runs now enforce an explicit immutability contract: the host workspace is bind-mounted read-only and the writable `/jaiph/workspace` inside the container is a sandbox-local copy-on-write layer discarded on exit. The only persistence channel from a Docker run to the host is the run-artifacts directory (`/jaiph/run` → host `.jaiph/runs`). Non-Docker (local) runs are unaffected. *(The automatic `workspace.patch` teardown export originally shipped here has been superseded by `artifacts.save_patch()` and removed — see the cleanup entry above.)* Docs updated (`docs/sandboxing.md`, `docs/architecture.md`, `docs/artifacts.md`). +- **Feature — Docker:** Default Docker when not CI or unsafe — Docker sandboxing is now **on by default** for local development. When neither `CI=true` nor `JAIPH_UNSAFE=true` is set in the environment, `runtime.docker_enabled` defaults to `true`. In CI environments or when `JAIPH_UNSAFE=true` is set, the default is `false`. Explicit overrides (`JAIPH_DOCKER_ENABLED` env var or in-file `runtime.docker_enabled`) always take precedence over the default rule. `JAIPH_UNSAFE=true` is the new explicit escape hatch for local development when Docker is unwanted. Implementation: `resolveDockerConfig()` in `src/runtime/docker.ts`. Unit tests for all env combinations added. Docs updated (`docs/sandboxing.md`, `docs/configuration.md`). +- **Feature — Docker:** Harden Docker execution environment — Docker sandboxing now enforces least-privilege defaults and explicit boundary controls. Containers launch with `--cap-drop ALL --cap-add SYS_ADMIN --security-opt no-new-privileges`, dropping all Linux capabilities except the one required for fuse-overlayfs and preventing privilege escalation. A mount denylist rejects dangerous host paths (`/`, `/var/run/docker.sock`, `/run/docker.sock`, `/proc`, `/sys`, `/dev` and their subpaths) at validation time with `E_VALIDATE_MOUNT` — both in `validateMounts` and at `buildDockerArgs` time. An environment variable denylist (`SSH_*`, `GPG_*`, `AWS_*`, `GCP_*`, `AZURE_*`, `GOOGLE_*`, `DOCKER_*`, `KUBE*`, `NPM_TOKEN*`) prevents host credentials from leaking into the container; only `JAIPH_*` (except `JAIPH_DOCKER_*`) and agent prefixes (`ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`) cross the container boundary. New exports: `validateMountHostPath`, `isEnvDenied`, `ENV_DENYLIST_PREFIXES`. Documentation adds a threat-model section (what Docker does and does not protect against), a failure-modes reference table (`E_DOCKER_*` / `E_VALIDATE_MOUNT` / `E_TIMEOUT`), expanded network-mode guidance, and the env denylist specification. Implementation: `src/runtime/docker.ts` (mount denylist, env denylist, security flags), `src/runtime/docker.test.ts` (unit tests for all new paths). Docs updated (`docs/sandboxing.md`). +- **Feature — Language:** Optional module manifest keys (`module.name`, `module.version`, `module.description`) — The module-level `config { }` block now accepts three optional descriptive metadata keys: `module.name`, `module.version`, and `module.description`. All three are strings, all optional, and purely informational — they do not affect agent, run, or runtime behavior. Values are stored on `WorkflowMetadata.module` and round-trip through `jaiph format`. No semver validation is applied to `module.version`; any quoted string is accepted. Workflow-level `config` blocks reject `module.*` keys with `E_PARSE`, consistent with the existing `runtime.*` workflow guard. Future features (e.g. MCP tool metadata) may consume these fields. Implementation: `ALLOWED_KEYS` and `assignConfigKey` in `src/parse/metadata.ts`, `WorkflowMetadata.module` in `src/types.ts`, formatter round-trip in `src/format/emit.ts`, workflow-level rejection in `src/parse/workflows.ts`. Unit tests cover happy path, partial keys, coexistence with other config keys, formatter round-trip, and workflow-level rejection. Docs updated (`docs/configuration.md`, `docs/grammar.md`). +- **Breaking — Docker:** Strict image contract and official GHCR runtime images — Docker mode now enforces a strict contract: every Docker image used by Jaiph must already contain a working `jaiph` CLI. Jaiph no longer auto-builds derived images or bootstraps itself into containers at runtime (no `npm pack`, no `npm install -g` into arbitrary base images). If the selected image lacks `jaiph`, the run fails immediately with `E_DOCKER_NO_JAIPH` and actionable guidance. The default `runtime.docker_image` is now `ghcr.io/jaiphlang/jaiph-runtime:` (matching the installed jaiph version), replacing the previous `node:20-bookworm` default. Official runtime images are published to GHCR: `ghcr.io/jaiphlang/jaiph-runtime:` for release tags, `:nightly` for the nightly branch, and `:latest` as a convenience alias. The official image includes Node.js, jaiph, `fuse-overlayfs`, and a non-root `jaiph` user (UID 10001); it does not include agent CLIs to keep the image minimal. The `jaiph init` Dockerfile template now extends the official image (`FROM ghcr.io/jaiphlang/jaiph-runtime:nightly`) and only adds agent CLIs (Claude Code, cursor-agent), instead of building from `ubuntu:latest` with a full install chain. Removed functions: `ensureLocalRuntimeImage`, `buildRuntimeImageFromLocalPackage`, `autoRuntimeImageTag`, `imageConfiguredUser`, `imageHomeDir`. Added: `verifyImageHasJaiph`, `GHCR_IMAGE_REPO`, `resolveDefaultImageTag`. CI: new `.github/workflows/docker-publish.yml` publishes the runtime image on release tags and nightly pushes. Implementation: `src/runtime/docker.ts`, `src/cli/commands/init.ts`, `docker/Dockerfile.runtime`. Unit and E2E tests updated for the strict contract — regression test confirms images without jaiph fail with `E_DOCKER_NO_JAIPH`. Docs updated (`docs/sandboxing.md`, `docs/configuration.md`, `docs/cli.md`, `docs/architecture.md`). +- **Feature — Language/Runtime:** Explicit nested managed calls in argument position — Call arguments can now contain nested managed calls using `run` or `ensure` keywords explicitly: `run foo(run bar())`, `run foo(ensure rule_bar())`, and `run foo(run \`echo "aaa"\`())`. The nested call executes first and its result is passed as a single argument to the outer call. Bare call-like forms in argument position are rejected at compile time: `run foo(bar())` → `E_VALIDATE` with an actionable message telling the user to add `run` or `ensure`. Bare inline script calls in argument position (`run foo(\`echo aaa\`())`) are also rejected with guidance. The explicit capture-then-pass form (`const x = run bar()` followed by `run foo(x)`) remains valid. Bare call-like forms in `const` assignments (`const x = bar()`) are also rejected — use `const x = run bar()`. The formatter round-trips explicit nested forms correctly, including the inline script variant. The runtime evaluates nested managed argument tokens (workflows, scripts, rules, and inline scripts) before passing the result to the outer call. Implementation: validator (`src/transpile/validate.ts` — `validateNestedManagedCallArgs` extended for inline script detection), runtime (`src/runtime/kernel/node-workflow-runtime.ts` — `managed_inline_script` token kind, `parseInlineScriptAt`, `resolveArgsRawSync` fast path), formatter (`src/format/emit.ts` — `parseInlineScriptArg`, inline script formatting in `formatArgs`). Regression tests added for all valid and invalid forms. Docs updated (`docs/language.md`, `docs/grammar.md`, `docs/jaiph-skill.md`). + # 0.9.2 ## Summary @@ -14,7 +36,7 @@ - **Breaking — Runtime:** Remove `JAIPH_LIB` — The Node runtime no longer sets `JAIPH_LIB`, and isolated script subprocesses no longer receive it (`run-step-exec.ts`). `resolveRuntimeEnv` still deletes inherited `JAIPH_LIB` so a parent shell cannot inject a stale path. Workflows that used `source "$JAIPH_LIB/…"` must use `JAIPH_WORKSPACE`-relative paths, `import script`, or inline bash. Project-scoped **`.jaiph/libs/`** (`jaiph install`) is unchanged. - **Docs / E2E:** Documentation and tests no longer describe or assert `JAIPH_LIB` / `.jaiph/lib` (singular). - **Feature — Runtime:** Heartbeat file in run directory — The runtime now writes a `heartbeat` file (containing epoch-ms timestamp) to the run directory (`.jaiph/runs//