diff --git a/.jaiph/async.jh b/.jaiph/async.jh
index c7348434..8abcd209 100755
--- a/.jaiph/async.jh
+++ b/.jaiph/async.jh
@@ -3,13 +3,17 @@
const prompt_text = "Say: Greetings! I am [model name]."
workflow cursor_say_hello(name) {
- config { agent.backend = "cursor" }
+ config {
+ agent.backend = "cursor"
+ }
const response = prompt "${prompt_text}"
log response
}
workflow claude_say_hello(name) {
- config { agent.backend = "claude" }
+ config {
+ agent.backend = "claude"
+ }
const response = prompt "${prompt_text}"
log response
}
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index 3e3e5781..e2dc4c11 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -8,14 +8,14 @@ import "jaiphlang/queue" as queue
import "jaiphlang/artifacts" as artifacts
import "./docs_parity.jh" as docs
import "./ensure_ci_passes.jh" as ci
-import "./git.jh" as git
+import "jaiphlang/git" as git
config {
- agent.backend = "cursor"
- agent.default_model = "gpt-5.3-codex"
- agent.cursor_flags = "--force"
- # agent.backend = "claude"
- # agent.claude_flags = "--permission-mode bypassPermissions"
+ # agent.backend = "cursor"
+ # agent.default_model = "composer-2"
+ # agent.cursor_flags = "--force"
+ agent.backend = "claude"
+ agent.claude_flags = "--permission-mode bypassPermissions"
}
const code_philosophy = """
@@ -286,7 +286,7 @@ workflow default(name) {
run docs.update_from_task(task)
run queue.remove_completed_task(task_header)
- const patch_file = run git.patch(task)
- const target_path = run artifacts.save(patch_file, patch_file)
- return target_path
+ const patch_file = run git.commit(task)
+ run artifacts.save(patch_file)
+ return patch_file
}
diff --git a/.jaiph/libs/jaiphlang/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh
index e23b64d0..fb68a0cb 100644
--- a/.jaiph/libs/jaiphlang/artifacts.jh
+++ b/.jaiph/libs/jaiphlang/artifacts.jh
@@ -10,27 +10,57 @@
# import "jaiphlang/artifacts" as artifacts
#
# workflow default() {
-# run artifacts.save("./build/output.bin", "build-output.bin")
+# # Single file:
+# run artifacts.save("./build/output.bin")
+#
+# # Or several files: newline-separated list of paths.
+# const paths = """
+# a.txt
+# b/nested.txt
+# """
+# run artifacts.save(paths)
# }
#
script save_script = ```
set -euo pipefail
ARTIFACTS_DIR="${JAIPH_ARTIFACTS_DIR:?JAIPH_ARTIFACTS_DIR is not set}"
- src="$1"
- dest_name="$2"
- if [[ ! -f "$src" ]]; then
- printf 'artifacts save: file not found: %s\n' "$src" >&2
+ paths_list="$1"
+ out=""
+
+ while IFS= read -r line || [[ -n "${line-}" ]]; do
+ [[ -z "${line//[[:space:]]/}" ]] && continue
+ src="${line#"${line%%[![:space:]]*}"}"
+ src="${src%"${src##*[![:space:]]}"}"
+ if [[ ! -f "$src" ]]; then
+ printf 'artifacts save: file not found: %s\n' "$src" >&2
+ exit 1
+ fi
+ if [[ "$src" = /* ]]; then
+ relpath="$(basename -- "$src")"
+ else
+ relpath="${src#./}"
+ fi
+ dest="${ARTIFACTS_DIR}/${relpath}"
+ mkdir -p "$(dirname -- "$dest")"
+ cp -- "$src" "$dest"
+ if [[ -n "$out" ]]; then
+ out+=$'\n'
+ fi
+ out+="$dest"
+ done <<<"$paths_list"
+
+ if [[ -z "$out" ]]; then
+ printf 'artifacts save: no paths in list\n' >&2
exit 1
fi
- dest="${ARTIFACTS_DIR}/${dest_name}"
- mkdir -p "$(dirname "$dest")"
- cp -- "$src" "$dest"
- printf '%s' "$dest"
+ printf '%s' "$out"
```
-# 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 save_script(local_path, name)
+# `paths` is a single file path or a newline-separated list of file paths.
+# Each file is copied under the same relative name as in the list
+# (leading `./` stripped; absolute paths use basename only).
+# Returns the absolute destination paths, one per line, in the same order.
+export workflow save(paths) {
+ return run save_script(paths)
}
diff --git a/.jaiph/git.jh b/.jaiph/libs/jaiphlang/git.jh
similarity index 50%
rename from .jaiph/git.jh
rename to .jaiph/libs/jaiphlang/git.jh
index 2450aa5b..8cf01eea 100755
--- a/.jaiph/git.jh
+++ b/.jaiph/libs/jaiphlang/git.jh
@@ -1,23 +1,33 @@
#!/usr/bin/env jaiph
-script git_inside_worktree = `git rev-parse --is-inside-work-tree >/dev/null 2>&1`
+script git_inside_worktree = `git rev-parse --is-inside-work-tree 2>&1`
script git_porcelain_empty = `test -z "$(git status --porcelain)"`
script git_porcelain_nonempty = `test -n "$(git status --porcelain)"`
+script git_mark_workspace_safe = `git config --global --add safe.directory "$(pwd)"`
+
+# format-patch emits real diff to stdout only with --stdout; otherwise git writes *.patch files and stdout is only the path.
+script git_create_patch_from_commit = `git config --global --add safe.directory "$(pwd)" && git format-patch -1 HEAD --stdout > $1`
+
rule in_git_repo() {
+ run git_mark_workspace_safe()
run git_inside_worktree() catch (err) {
fail "not inside a git repository"
}
}
rule branch_clean() {
- run git_porcelain_empty()
+ run git_porcelain_empty() catch (err) {
+ fail "git working tree is not clean"
+ }
}
rule has_changes() {
- run git_porcelain_nonempty()
+ run git_porcelain_nonempty() catch (err) {
+ fail "git working tree has no changes"
+ }
}
rule is_clean() {
@@ -26,55 +36,43 @@ rule is_clean() {
}
workflow commit(task) {
- ensure in_git_repo()
-
- ensure has_changes() catch (err) {
- log "No changes to commit — skipping."
- return ""
+ config {
+ agent.backend = "cursor"
+ agent.cursor_flags = "--force"
+ agent.default_model = "auto"
}
- prompt """
- Commit the current repository changes now.
+ ensure in_git_repo()
+ ensure has_changes()
+ const response = prompt """
+ Please commit current changes and respond with a commit message and
+ suggested patch file name (excluding extension).
+
Requirements for commit message:
1. Write a commit message - first line in imperative mood, under 72 chars.
2. Start with the common prefix like 'Feat:', 'Fix:', 'Refactor:' etc.
3. Write a body paragraph with more details about the change.
-
+
Requirements for commit:
1. Review current git changes (git diff --stat, git status).
2. Stage all relevant changes with git add.
3. Create exactly one commit.
4. Do not push.
5. Remove files that are not relevant to the commit and not git ignored.
-
+
Changes were made for the following task:
${task}
"""
+ returns "{ message: string, patch_file_name: string }"
+
+ const patch_file_name = "${response.patch_file_name}.patch"
+
+ run git_create_patch_from_commit(patch_file_name)
+
+ return patch_file_name
}
-# Writes a unified diff (HEAD vs working tree, excluding `.jaiph/`) to `dest`.
-# Returns `dest` (relative path). `task` is reserved for callers / future naming.
-script write_tree_patch = ```
- set -euo pipefail
- dest="$1"
- mkdir -p "$(dirname "$dest")"
- diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
- if [[ -z "${diff_out}" ]]; then
- git add -N . -- ':!.jaiph/' 2>/dev/null || true
- diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
- git reset HEAD -- . 2>/dev/null || true
- fi
- if [[ -n "${diff_out}" ]]; then
- printf '%s\n' "${diff_out}" > "$dest"
- else
- : > "$dest"
- fi
- printf '%s' "$dest"
-```
-
-workflow patch(task) {
- ensure in_git_repo()
- const dest = ".jaiph/tmp/engineer-workspace.patch"
- return run write_tree_patch(dest)
+workflow default(task) {
+ return run commit(task)
}
diff --git a/.jaiph/main.jh b/.jaiph/main.jh
index 5f3db9fd..aaf143f7 100755
--- a/.jaiph/main.jh
+++ b/.jaiph/main.jh
@@ -7,7 +7,7 @@
import "./engineer.jh" as implement
import "./architect_review.jh" as architect
-import "./git.jh" as git
+import "jaiphlang/git" as git
workflow default() {
ensure git.is_clean()
diff --git a/.jaiph/qa.jh b/.jaiph/qa.jh
index f4d8166d..af08b4a7 100755
--- a/.jaiph/qa.jh
+++ b/.jaiph/qa.jh
@@ -1,7 +1,7 @@
#!/usr/bin/env jaiph
import "./ensure_ci_passes.jh" as ci
-import "./git.jh" as git
+import "jaiphlang/git" as git
config {
agent.backend = "claude"
@@ -9,35 +9,35 @@ config {
}
script read_contributing_docs = ```
-test -f docs/contributing.md || {
- echo "docs/contributing.md not found (run from repo root)" >&2
- return 1
-}
-awk '
-/^---$/ {
- if (sec == 0) { sec = 1; next }
- if (sec == 1) { sec = 2; next }
-}
-sec == 1 { next }
-sec == 2 { print }
-' docs/contributing.md
+ test -f docs/contributing.md || {
+ echo "docs/contributing.md not found (run from repo root)" >&2
+ return 1
+ }
+ awk '
+ /^---$/ {
+ if (sec == 0) { sec = 1; next }
+ if (sec == 1) { sec = 2; next }
+ }
+ sec == 1 { next }
+ sec == 2 { print }
+ ' docs/contributing.md
```
-script read_txtar_format_spec = `cat compiler-tests/README.md`
+script read_txtar_format_spec = `cat test-fixtures/compiler-txtar/README.md`
script read_txtar_fixture_names = ```
-for f in compiler-tests/*.txt; do
- echo "=== FILE: $f ==="
- grep '^=== ' "$f" | sed 's/^=== / /'
-done
+ for f in test-fixtures/compiler-txtar/*.txt; do
+ echo "=== FILE: $f ==="
+ grep '^=== ' "$f" | sed 's/^=== / /'
+ done
```
script read_txtar_fixtures = ```
-for f in compiler-tests/*.txt; do
- echo "========== $f =========="
- cat "$f"
- echo ""
-done
+ for f in test-fixtures/compiler-txtar/*.txt; do
+ echo "========== $f =========="
+ cat "$f"
+ echo ""
+ done
```
script new_qa_gap_report_path = `echo ".jaiph/tmp/qa_gap_report_$(date +%Y-%m-%d_%H-%M-%S).md"`
@@ -45,103 +45,103 @@ script new_qa_gap_report_path = `echo ".jaiph/tmp/qa_gap_report_$(date +%Y-%m-%d
script write_gap_report_pointer = `printf '%s\n' "$1" > .jaiph/tmp/qa_gap_report_active.txt`
script gap_report_nonempty = ```
-p="$(cat .jaiph/tmp/qa_gap_report_active.txt 2>/dev/null)" || return 1
-[ -z "$p" ] && return 1
-test -s "$p"
+ p="$(cat .jaiph/tmp/qa_gap_report_active.txt 2>/dev/null)" || return 1
+ [ -z "$p" ] && return 1
+ test -s "$p"
```
script read_gap_report = ```
-p="$(cat .jaiph/tmp/qa_gap_report_active.txt)"
-cat "$p"
+ p="$(cat .jaiph/tmp/qa_gap_report_active.txt)"
+ cat "$p"
```
script save_gap_report_file = `printf '%s\n' "$1" > "$2"`
const analyze_gaps_prompt = """
-
- You are a QA engineer analyzing the Jaiph codebase for test coverage gaps.
-
-
- ${contributing_docs}
-
-
-
- ${txtar_format}
-
-
-
- ${txtar_names}
-
-
- Use the testing philosophy and layer locations from contributing_docs above.
- The test layers are:
-
- 1. **Compiler tests (txtar)** — language-agnostic fixtures in compiler-tests/*.txt.
- Each test case is a .jh source with an expected outcome (ok or error).
- These test parse + validate without execution. This is the PRIMARY layer
- for compiler error paths and valid-source acceptance.
-
- 2. **Module unit tests** — colocated src/**/*.test.ts for internal API testing
- (AST structure, helper functions, display formatting).
-
- 3. **Compiler acceptance** — src/transpile/*.acceptance.test.ts for cross-module
- and integration scenarios.
-
- 4. **Runtime e2e** — e2e/tests/*.sh for full execution (compile + run + verify output).
-
- 5. **CLI display** — src/cli/run/display.test.ts and progress tree rendering tests.
-
-
- Your task:
-
- 1. Read every source file in src/parse/*.ts. For each fail() call, check if
- the error is covered by either:
- - A txtar test case in compiler-tests/*.txt (check existing_txtar_tests above)
- - A colocated TS test in src/parse/*.test.ts
- List fail() calls with no coverage in either layer. These are COMPILER gaps —
- they should be fixed with txtar test cases.
-
- 2. Read src/transpile/validate.ts, src/transpile/validate-ref-resolution.ts,
- src/transpile/validate-string.ts, src/transpile/validate-prompt-schema.ts,
- and src/transpile/shell-jaiph-guard.ts. For each jaiphError() call, check
- coverage in txtar fixtures and TS tests. List uncovered paths.
- These are COMPILER gaps — txtar test cases.
-
- 3. Read e2e/tests/*.sh file names and compare against the feature list in
- docs/grammar.md and docs/cli.md. List features with no runtime e2e test.
- Also check if all e2e test files are registered in e2e/test_all.sh.
- These are RUNTIME gaps — e2e bash tests.
-
- 4. Read src/cli/run/display.ts and src/cli/run/progress.ts. For each
- formatting branch (start line, end line, prompt label, async label,
- depth indentation, TTY vs non-TTY), check test coverage in
- display.test.ts. List gaps. Focus on response tree rendering edge cases.
- These are DISPLAY gaps — TS unit tests.
-
- 5. Read src/cli/commands/*.ts. List error paths and edge cases with no
- test coverage. These are CLI gaps — TS unit tests or e2e tests.
-
- Output a structured gap report. For each gap, specify which test layer
- should be used:
-
- ## Gap: [short description]
- - File: [source file with untested code path]
- - Code path: [specific branch/function/line range]
- - Why it matters: [what bug this could hide]
- - Test layer: **txtar** | **unit** | **acceptance** | **e2e**
- - Priority: [high | medium | low]
-
- For **txtar** gaps, also include the suggested test case in txtar format:
- \`\`\`
- === suggested test name
- # @expect error E_CODE "substring"
- --- input.jh
-
- \`\`\`
-
- Sort by priority descending. Be thorough but skip trivial paths.
-
-
+
+ You are a QA engineer analyzing the Jaiph codebase for test coverage gaps.
+
+
+ ${contributing_docs}
+
+
+
+ ${txtar_format}
+
+
+
+ ${txtar_names}
+
+
+ Use the testing philosophy and layer locations from contributing_docs above.
+ The test layers are:
+
+ 1. **Compiler tests (txtar)** — language-agnostic fixtures in compiler-tests/*.txt.
+ Each test case is a .jh source with an expected outcome (ok or error).
+ These test parse + validate without execution. This is the PRIMARY layer
+ for compiler error paths and valid-source acceptance.
+
+ 2. **Module unit tests** — colocated src/**/*.test.ts for internal API testing
+ (AST structure, helper functions, display formatting).
+
+ 3. **Compiler acceptance** — src/transpile/*.acceptance.test.ts for cross-module
+ and integration scenarios.
+
+ 4. **Runtime e2e** — e2e/tests/*.sh for full execution (compile + run + verify output).
+
+ 5. **CLI display** — src/cli/run/display.test.ts and progress tree rendering tests.
+
+
+ Your task:
+
+ 1. Read every source file in src/parse/*.ts. For each fail() call, check if
+ the error is covered by either:
+ - A txtar test case in compiler-tests/*.txt (check existing_txtar_tests above)
+ - A colocated TS test in src/parse/*.test.ts
+ List fail() calls with no coverage in either layer. These are COMPILER gaps —
+ they should be fixed with txtar test cases.
+
+ 2. Read src/transpile/validate.ts, src/transpile/validate-ref-resolution.ts,
+ src/transpile/validate-string.ts, src/transpile/validate-prompt-schema.ts,
+ and src/transpile/shell-jaiph-guard.ts. For each jaiphError() call, check
+ coverage in txtar fixtures and TS tests. List uncovered paths.
+ These are COMPILER gaps — txtar test cases.
+
+ 3. Read e2e/tests/*.sh file names and compare against the feature list in
+ docs/grammar.md and docs/cli.md. List features with no runtime e2e test.
+ Also check if all e2e test files are registered in e2e/test_all.sh.
+ These are RUNTIME gaps — e2e bash tests.
+
+ 4. Read src/cli/run/display.ts and src/cli/run/progress.ts. For each
+ formatting branch (start line, end line, prompt label, async label,
+ depth indentation, TTY vs non-TTY), check test coverage in
+ display.test.ts. List gaps. Focus on response tree rendering edge cases.
+ These are DISPLAY gaps — TS unit tests.
+
+ 5. Read src/cli/commands/*.ts. List error paths and edge cases with no
+ test coverage. These are CLI gaps — TS unit tests or e2e tests.
+
+ Output a structured gap report. For each gap, specify which test layer
+ should be used:
+
+ ## Gap: [short description]
+ - File: [source file with untested code path]
+ - Code path: [specific branch/function/line range]
+ - Why it matters: [what bug this could hide]
+ - Test layer: **txtar** | **unit** | **acceptance** | **e2e**
+ - Priority: [high | medium | low]
+
+ For **txtar** gaps, also include the suggested test case in txtar format:
+ \`\`\`
+ === suggested test name
+ # @expect error E_CODE "substring"
+ --- input.jh
+
+ \`\`\`
+
+ Sort by priority descending. Be thorough but skip trivial paths.
+
+
"""
workflow analyze_gaps() {
@@ -220,7 +220,7 @@ workflow write_tests() {
${gap_report}
-"""
+ """
}
script mkdir_tmp_jaiph_qa = `mkdir -p .jaiph/tmp`
diff --git a/.jaiph/sandbox.jh b/.jaiph/sandbox.jh
new file mode 100755
index 00000000..4cbc212c
--- /dev/null
+++ b/.jaiph/sandbox.jh
@@ -0,0 +1,9 @@
+#!/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/simplifier.jh b/.jaiph/simplifier.jh
index fafe9505..96356475 100644
--- a/.jaiph/simplifier.jh
+++ b/.jaiph/simplifier.jh
@@ -1,7 +1,7 @@
#!/usr/bin/env jaiph
import "./ensure_ci_passes.jh" as ci
-import "./git.jh" as git
+import "jaiphlang/git" as git
config {
agent.backend = "claude"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eeb070f8..add83fb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
# Unreleased
+- **Cleanup — remove `JAIPH_TEST_MODE` event suppression from production runtime code:** `RuntimeEventEmitter.emitStep` / `emitLog` no longer read `this.env.JAIPH_TEST_MODE` to decide whether to write `__JAIPH_EVENT__` lines to stderr. A construction-time `suppressLiveEvents?: boolean` option replaces the per-call env check: `NodeWorkflowRuntime` accepts it in its options and forwards it to `RuntimeEventEmitter`. `node-test-runner.ts` passes `suppressLiveEvents: true` when constructing the in-process runtime for `test_run_workflow` steps so `node --test` reporter output stays clean. `JAIPH_TEST_MODE: "1"` is still set in the test runner's env — but only for `prompt.ts`'s mock-mode selection, not event emission. No other production caller constructs `NodeWorkflowRuntime` directly, so the spawned `node-workflow-runner.js` child defaults to `suppressLiveEvents: false` and live events stream to stderr exactly as before. Durable `appendRunSummaryLine` writes to `run_summary.jsonl` are unchanged in either mode. Existing in-process unit tests under `node-workflow-runtime.artifacts.test.ts` pass the new option through their `NodeWorkflowRuntime` constructions.
+
+- **Repo — `node-workflow-runtime.ts` split:** The 1915-LoC `src/runtime/kernel/node-workflow-runtime.ts` god file is split into the orchestrator plus three focused sibling modules under `src/runtime/kernel/`. No behavior changes — pure relocation; existing tests pass unchanged (helpers re-imported from their new location where needed).
+ - **`runtime-arg-parser.ts`** — every stateless free helper that used to live above the `NodeWorkflowRuntime` class (`interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`), the `BARE_IDENT_RE` / `MAX_EMBED` / `MAX_RECURSION_DEPTH` constants, and the `ParsedArgToken` / `PromptSchemaField` types. Direct unit tests added in `runtime-arg-parser.test.ts`.
+ - **`runtime-event-emitter.ts`** — `RuntimeEventEmitter` owns `emitWorkflow`, `emitStep`, `emitPromptStepStart`, `emitPromptStepEnd`, `emitPromptEvent`, `emitLog`, plus the monotonic step and prompt sequence counters. Constructed with `{ runId, runDir, env, getFrameStack, getAsyncIndices }`. No more direct `process.stderr.write(__JAIPH_EVENT__ …)` scattered through the runtime.
+ - **`runtime-mock.ts`** — `executeMockBodyDef` and `executeMockShellBody` move here as exported functions taking `{ ref, args, env, cwd, executeStepsBack }` (the last is a callback so steps-kind mocks dispatch back into the runtime). The `require("node:child_process")` call that shadowed ESM imports inside `executeMockShellBody` is gone — replaced by a top-of-file `import`.
+ - The orchestrator (`node-workflow-runtime.ts`) keeps the `NodeWorkflowRuntime` class, workflow/step orchestration (`runDefault`, `runNamedWorkflow`, `executeSteps`, `executeStep`, `runRecoverBody`, `runPromptStep`, frame and scope management), async-handle bookkeeping (`getAsyncIndices`, `getFrameStack`), and heartbeat (`startHeartbeat`, `stopHeartbeat`, `writeHeartbeat`). Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports.
+
+- **Breaking — Inbox dispatch is always sequential** — The optional parallel inbox mode is removed: there is no `run.inbox_parallel` config key, no `JAIPH_INBOX_PARALLEL` environment variable (it is ignored), and no `JAIPH_INBOX_PARALLEL_LOCKED` shim. Route targets for a queued message always run **one after another** in declaration order on the `channel` line, inside `NodeWorkflowRuntime`’s `drainWorkflowQueue`. Using `run.inbox_parallel = …` in a `config { … }` block is `E_PARSE: unknown config key: run.inbox_parallel`. Docs and E2E now match sequential-only semantics; unit tests cover the unknown key and parity of dispatch event order with and without the old env var set.
+
+- **Fix — CLI failure footer:** `Output of failed step` and the footer `out:` / `err:` paths now resolve from the **last** non-zero `STEP_END` in `run_summary.jsonl` (append order), not the first. The first failure line could be a recovered `catch`/`ensure` attempt, a stray record, or unrelated noise; the last failure matches the terminal step (the one the progress tree marks as failed). **`src/cli/shared/errors.test.ts`** covers multiple non-zero `STEP_END` lines.
+- **Fix — Docker default image tag:** `curl` / `docs/install` copied only `dist/src` into `~/.local/bin/.jaiph`, so the CLI could not read `package.json` and defaulted the sandbox image to `ghcr.io/jaiphlang/jaiph-runtime:nightly` even for stable installs. The installer now copies `package.json` beside `src/`, and `resolveDefaultDockerImageTag` checks both the installer layout and the npm `dist/src/runtime` layout.
+- **Repo — Test directory consolidation:** Consolidated the five-way test directory split (`src/**/*.test.ts`, `test/`, `tests/`, `compiler-tests/`, `golden-ast/`) into three test "places" plus two clearly named support directories. File moves:
+ - `src/compiler-test-runner.ts` → `test-infra/compiler-test-runner.ts`
+ - `src/golden-ast-runner.ts` → `test-infra/golden-ast-runner.ts`
+ - `compiler-tests/` → `test-fixtures/compiler-txtar/` (README preserved)
+ - `golden-ast/` → `test-fixtures/golden-ast/`
+ - `tests/e2e-samples/landing-page.spec.ts` → `e2e/playwright/landing-page.spec.ts`
+ - `tests/e2e-samples/docs-site.ts` → `e2e/playwright/docs-site.ts`
+ - `test/run-summary-jsonl.test.ts`, `test/signal-lifecycle.test.ts`, `test/tty-running-timer.test.ts` → `integration/`
+ - `test/sample-build.test.ts` (2814 LoC) split into 7 focused files under `integration/sample-build/` (each ≤500 LoC) plus a shared `helpers.ts`
+ - `test/fixtures/`, `test/expected/` → `test-fixtures/sample-build/`
+ - `test/` and `tests/` directories removed.
+
+ Final layout: `src/**/*.test.ts` (unit, colocated), `integration/` (integration tests), `e2e/` (shell + Playwright), `test-fixtures/` (compiler-txtar, golden-ast, sample-build), `test-infra/` (test runners). `package.json` scripts and `tsconfig.json` updated. No test logic, assertions, or fixtures changed.
+
# 0.9.3
## Summary
@@ -15,7 +41,7 @@
- **Breaking — Runtime config:** `runtime.docker_timeout` renamed to `runtime.docker_timeout_seconds` to make the unit explicit. The old key produces an `E_PARSE` migration message. `DockerRunConfig.timeout` renamed to `timeoutSeconds` internally.
- **Docker:** Default container execution timeout is **3600** seconds (one hour), up from 300, via `resolveDockerConfig` / `runtime.docker_timeout_seconds` when not overridden by `JAIPH_DOCKER_TIMEOUT` or in-file config.
- **Docker:** `reportResult` fallback — when `discoverDockerRunDir` cannot match the expected `run_id`, the CLI now prints the sandbox runs root and the expected `run_id` instead of emitting just "Workflow execution failed." Paired with a rewritten `76_docker_failure_parity.sh` E2E that compares full normalized output between Docker and no-sandbox modes for both script-step and rule-match failures.
-- **Library:** `jaiphlang/artifacts` provides `save(local_path, name)` via a named `save_script` and drops the unpublished git-oriented helpers (`save_patch`, `apply_patch`) and standalone `artifacts.sh`.
+- **Library:** `jaiphlang/artifacts` provides a single `save(paths)` workflow that accepts either a file path or a newline-separated list of paths; the destination relpath is derived from the source path (leading `./` stripped; absolute sources use `basename` only). Replaces the prior two-argument `save(local_path, name)` and drops the unpublished git-oriented helpers (`save_patch`, `apply_patch`) and standalone `artifacts.sh`. See [Libraries](docs/libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox).
- **Language:** `return ` — bare identifiers are now accepted in return position. `return response` is sugar for `return "${response}"`, resolved against the same scope rules used for `${ident}` interpolation and bare-identifier call arguments (`const`, capture, or parameter). Unknown identifiers (`return missing_name` where `missing_name` is not in scope) produce a precise `E_VALIDATE` unknown-identifier error naming the missing binding. Previously, bare identifiers in return position fell through to the catch-all "inline shell steps are forbidden" diagnostic, which was incorrect — the user was not writing a shell statement, and the suggested fix (explicit script block) did not solve the problem. Both `return response` and `return "${response}"` are valid and equivalent; existing interpolated return forms are unchanged. Parser updated in all return-position paths (top-level workflow body, brace blocks, catch/recover bodies). Unit tests cover bare-identifier returns from `const`, parameters, and catch bindings; compiler tests cover acceptance and unknown-identifier rejection; E2E test covers end-to-end propagation.
- **Language:** Immutable binding enforcement — `const`, parameter, capture, and `script` names are now immutable. Rebinding a parameter via `const`, declaring duplicate `const` names in the same scope, or colliding a `script` name with an existing immutable binding are all rejected at compile time with `E_VALIDATE: cannot rebind immutable name "…"`. The error names the conflicting binding and where it was first bound. Existing files that shadowed parameters (e.g. `workflow default(x) { const x = … }`) must use distinct names. `examples/say_hello.jh` migrated as a reference.
- **Language:** `return run \`…\`(args)` and `log run \`…\`(args)` — inline scripts wrapped with explicit `run` now work in value positions (`return`, `log`, `logerr`). Bare inline scripts without `run` remain rejected at compile time with clear errors. Parser, validator, emitter, formatter, and runtime all updated. E2E and unit tests cover zero-arg and argument forms plus rejection paths.
diff --git a/QUEUE.md b/QUEUE.md
index f52504a5..72264987 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -13,123 +13,38 @@ Process rules:
***
-## Cleanup — consolidate the 5-way test directory split #dev-ready
+## Performance — investigate and fix slow installation
**Goal**
-Today there are five different places that contain "tests": `src/**/*.test.ts` (66 unit tests, adjacent to source), `test/` (4 integration files including a 2427-LoC `sample-build.test.ts`), `tests/e2e-samples/` (a single Playwright file), `compiler-tests/` (txtar fixtures), `golden-ast/` (fixtures + expected). Plus runners `src/compiler-test-runner.ts` and `src/golden-ast-runner.ts` mixed into the production source tree. A new contributor cannot tell where a new test belongs without reading the whole layout. Fix the structure in one pass.
-
-**Context (read before starting)**
-
-* The current `package.json` `test` script enumerates the test sources explicitly; this gives us a precise inventory of what is wired in:
- ```
- dist/test/*.test.js
- dist/src/**/*.test.js
- dist/src/**/*.acceptance.test.js
- dist/src/compiler-test-runner.js
- dist/src/golden-ast-runner.js
- ```
- Any move must update this script and keep the same test set running. Adding tests is out of scope; this is purely reorganization.
-* `src/compiler-test-runner.ts` and `src/golden-ast-runner.ts` are compiled and shipped in `dist/`, but they are test infrastructure (they consume fixtures, produce assertions). They should not live in `src/`.
-* `compiler-tests/README.md` already documents the txtar format — preserve that doc next to the fixtures it describes.
+`jaiph install` (and related dependency or bootstrap steps) feels unreasonably slow; find the dominant cost and improve it without weakening reproducibility (lockfile, shallow clone behavior, etc.).
**Scope**
-* **Move test infrastructure out of `src/`**:
- - `src/compiler-test-runner.ts` → `test-infra/compiler-test-runner.ts`
- - `src/golden-ast-runner.ts` → `test-infra/golden-ast-runner.ts`
- - `tsconfig.json` and `package.json` `test` script updated to reference the new locations.
-* **Rename and group fixture directories**:
- - `compiler-tests/` → `test-fixtures/compiler-txtar/` (preserves the README inside).
- - `golden-ast/` → `test-fixtures/golden-ast/` (preserves the `fixtures/` and `expected/` subdirs underneath).
- - Update path references in `test-infra/compiler-test-runner.ts` and `test-infra/golden-ast-runner.ts`.
-* **Fold the singleton Playwright test**:
- - `tests/e2e-samples/landing-page.spec.ts` → `e2e/playwright/landing-page.spec.ts`.
- - Update `playwright.config.ts` and the `test:samples` npm script accordingly.
- - Delete the now-empty `tests/` directory.
-* **Triage `test/` (4 files, 2960 LoC)**:
- - `test/run-summary-jsonl.test.ts` (178 LoC), `test/signal-lifecycle.test.ts` (220 LoC), `test/tty-running-timer.test.ts` (135 LoC) — keep in a renamed `integration/` directory. They are integration-flavored, not unit, and don't have an obvious adjacent home.
- - `test/sample-build.test.ts` (2427 LoC) — split. Read the file, group its tests by which subsystem they actually exercise, and move each group either next to that subsystem (`src/.../.integration.test.ts`) or into `integration/sample-build/.test.ts`. Aim for no resulting file over ~600 LoC. The split is the work; it is not optional.
- - Move `test/expected/` and `test/fixtures/` to `test-fixtures/sample-build/` if any test still references them after the split.
-* **Final layout** (target):
- ```
- src/**/*.test.ts # unit, adjacent (unchanged)
- src/**/*.acceptance.test.ts # acceptance, adjacent (unchanged)
- integration/**/*.test.ts # integration tests (was `test/`, after split)
- test-fixtures/compiler-txtar/ # was `compiler-tests/`
- test-fixtures/golden-ast/ # was `golden-ast/`
- test-fixtures/sample-build/ # if any sample-build fixtures survive the split
- test-infra/compiler-test-runner.ts # was `src/compiler-test-runner.ts`
- test-infra/golden-ast-runner.ts # was `src/golden-ast-runner.ts`
- e2e/ # shell + .jh (unchanged)
- e2e/playwright/landing-page.spec.ts # was `tests/e2e-samples/`
- ```
- Three test "places" instead of five (`src/`-adjacent, `integration/`, `e2e/`); plus two clearly named support directories (`test-fixtures/`, `test-infra/`).
-* Update `package.json` `test`, `test:compiler`, `test:golden-ast`, `test:samples`, `test:acceptance`, `test:ci`, `test:e2e` scripts to reference the new paths. Verify by running `npm test` end-to-end.
-
-**Non-goals**
-
-* Do not change any test's logic, assertions, or fixtures' contents. The goal is layout, not behavior.
-* Do not change the unit-tests-adjacent-to-source convention. That part works.
-* Do not delete any test (other than ones absorbed into the `sample-build.test.ts` split, where the original file goes away after redistribution).
+* Profile or instrument the install path (git clone, lockfile I/O, post-install) and document the top 1–3 contributors to latency.
+* Implement targeted fixes (e.g. avoid redundant work, reduce subprocess churn, cache safely) and verify wall-clock improvement on a cold and warm run where applicable.
**Acceptance criteria**
-* `npm test` passes with the same test count (or higher, if the `sample-build` split surfaces previously-bundled cases as separate tests). Test count must not decrease.
-* No file in `src/` is named `*-test-runner.ts`. Test infrastructure lives only in `test-infra/`.
-* No file under `integration/` exceeds ~600 LoC after the `sample-build` split.
-* The repo root no longer has both `test/` and `tests/`. (`tests/` is deleted after folding.)
-* `package.json` test scripts reference the new paths and the same test set runs in CI.
-* Commit message documents the file-move map (old → new) so reviewers can sanity-check that nothing was lost.
+* A short note in the commit or PR description states what was slow and what changed, with before/after rough timings on the same machine.
+* `jaiph install` behavior remains correct: same lockfile semantics and failure modes for bad URLs or missing refs.
+* `npm test` passes.
***
-## Refactor — split `src/runtime/kernel/node-workflow-runtime.ts` (1901 LoC) #dev-ready
+## Performance — investigate and fix slow workflow start (initial 2–4 s lag)
**Goal**
-`src/runtime/kernel/node-workflow-runtime.ts` is a 1901-LoC god file: ~280 LoC of free arg-parsing helpers above the class, then ~1620 LoC of `NodeWorkflowRuntime` spanning workflow orchestration, step execution, prompt step lifecycle, event emission, mock execution, frame stack management, and heartbeat I/O. Reading or modifying any one concern requires holding all of them in head. Split along clean seams so each concern is in a focused module.
-
-**Context (read before starting)**
-
-* This file is actively touched by the `Handle` task. If that task is in flight, **rebase on it before splitting** — do not do this work in parallel without coordinating, or the merge will be miserable.
-* The class has stateful internals (`runId`, `runDir`, `summaryFile`, `heartbeatTimer`, `frameStack`, `asyncIndices`, `env`, `cwd`, `graph`, `mockBodies`). The split must keep state in the class and move stateless helpers out, or pass state explicitly into the extracted modules. Do not invent a second source of truth.
-* Free helpers above the class (`interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `BARE_IDENT_RE`, `MAX_EMBED`, `MAX_RECURSION_DEPTH`, `sanitizeName`, `nowIso`) — all stateless. Safe to extract.
-* Methods that are pure event emission (`emitWorkflow`, `emitStep`, `emitPromptStepStart`, `emitPromptStepEnd`, `emitPromptEvent`, `emitLog`) all call `appendRunSummaryLine` and `process.stderr.write`. They depend on the class only for `runId`, `summaryFile`, and `getAsyncIndices()`. Can move to a module that takes those as constructor args.
-* Mock execution methods (`executeMockBodyDef`, `executeMockShellBody`) are largely self-contained and could move to a sibling module.
+When starting workflows (e.g. `jaiph run` / first step), users observe a 2–4 second delay before useful work; reduce that lag or explain and eliminate unnecessary startup work (JIT, imports, process spawn, discovery).
**Scope**
-Extract three new sibling modules under `src/runtime/kernel/`:
-
-* **`runtime-arg-parser.ts`** — every stateless free helper currently above the `NodeWorkflowRuntime` class:
- - `interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`
- - The `BARE_IDENT_RE`, `MAX_EMBED`, `MAX_RECURSION_DEPTH` constants
- - The `ParsedArgToken`, `PromptSchemaField` types if they are not used elsewhere in the class
- - **Required**: extracted helpers must have unit tests (some already do indirectly via runtime tests; new direct tests live in `runtime-arg-parser.test.ts`).
-* **`runtime-event-emitter.ts`** — a small class `RuntimeEventEmitter` constructed with `{ runId, asyncIndicesGetter, env }`, exposing `emitWorkflow`, `emitStep`, `emitPromptStepStart`, `emitPromptStepEnd`, `emitPromptEvent`, `emitLog`. The runtime constructs one and delegates. No more direct `process.stderr.write(__JAIPH_EVENT__ ...)` scattered through the runtime.
-* **`runtime-mock.ts`** — `executeMockBodyDef` and `executeMockShellBody` move here as exported functions taking `{ ref, args, env, cwd, executeStepsBack }` (the last is a callback so the mock can dispatch back into the runtime for `kind: "steps"` mocks). Removes the `require("node:child_process")` and `require("node:fs")` calls that currently shadow ESM imports inside the class body — that is a code smell that should die in this task.
-
-After the split, `node-workflow-runtime.ts` keeps only:
-* The `NodeWorkflowRuntime` class
-* Workflow/step orchestration (`runDefault`, `runNamedWorkflow`, `executeSteps`, `executeStep`, frame and scope management)
-* The async-handle bookkeeping (`getAsyncIndices`, `getFrameStack`)
-* Heartbeat (`startHeartbeat`, `stopHeartbeat`, `writeHeartbeat`)
-
-Target size for `node-workflow-runtime.ts` after split: ~1000–1200 LoC. Still large, but a single coherent concern (the orchestrator).
-
-**Non-goals**
-
-* Do not change behavior. Every existing test must still pass without modification.
-* Do not redesign the event format, the mock contract, or the arg-parser's accepted syntax. This is a relocation task only.
-* Do not split further than the three new modules listed. Over-decomposition is its own problem; this task is calibrated for one round of splitting.
-* Do not touch `node-workflow-runner.ts` (the CLI shim) or `run-step-exec.ts` (subprocess plumbing) — those are already correctly sized and out of scope.
+* Reproduce the lag with a minimal `.jh` workflow; trace Node startup, module load, and runtime init (`NodeWorkflowRuntime` and friends).
+* Address fixable costs (e.g. defer heavy work, lazy imports, avoid redundant file scans) without changing user-visible workflow semantics.
**Acceptance criteria**
-* `src/runtime/kernel/node-workflow-runtime.ts` is between 1000 and 1200 LoC after the split.
-* `src/runtime/kernel/runtime-arg-parser.ts`, `runtime-event-emitter.ts`, `runtime-mock.ts` exist and own their respective concerns.
-* `runtime-arg-parser.test.ts` exists with direct unit tests for the extracted helpers.
-* `npm test` passes with no test changes other than possibly importing helpers from their new location.
-* No `require("node:...")` calls inside class methods (they are replaced by top-of-file `import` statements as part of the mock extraction).
-* The new modules have no circular imports back into `node-workflow-runtime.ts`. Dependency direction is one-way: orchestrator → helpers/emitter/mock.
+* Documented repro (command + minimal file) and what was measured (time to first event / first step).
+* Measurable reduction in the cold-start path on a representative case, or a clear justification if the lag is irreducible (e.g. external subprocess).
+* `npm test` passes.
***
diff --git a/design/2026-05-12-agent-proxy.md b/design/2026-05-12-agent-proxy.md
new file mode 100644
index 00000000..a5d428e7
--- /dev/null
+++ b/design/2026-05-12-agent-proxy.md
@@ -0,0 +1,213 @@
+# agent-proxy — design doc
+
+*Phantom Token credential proxy for the jaiph Docker sandbox. Container holds only a placeholder; real credentials live on the host and never cross the sandbox boundary.*
+
+**Status:** design — ready for implementation
+**Date (UTC):** 2026-05-12
+
+## Problem
+
+jaiph's sandbox (`src/runtime/docker.ts`) deliberately drops every host credential channel: `SSH_*`, `GITHUB_TOKEN`, `GIT_*`, and anything outside the `JAIPH_/ANTHROPIC_/CURSOR_/CLAUDE_` env allowlist. Host `~/.ssh`, `~/.gitconfig`, and `~/.claude` are not mounted. Network egress is allowed by default.
+
+Container CLIs like `claude` therefore see no credentials and prompt `Not logged in`. Naively forwarding an API key as an env var would re-introduce exactly the exfiltration surface the sandbox was designed to remove — a prompt-injection attack on the agent could dump `process.env` at any time.
+
+```mermaid
+flowchart LR
+ subgraph HOST["macOS / Linux host"]
+ KC[(Keychain / libsecret / ~/.claude/.credentials.json)]
+ end
+ subgraph CT["jaiph sandbox container"]
+ CC[claude CLI]
+ end
+ CC -. blocked .-> KC
+ CC -->|HTTPS allowed| API[api.anthropic.com]
+ API -.->|401 no auth| CC
+ linkStyle 0,2 stroke:#a40000,stroke-dasharray: 4 3;
+```
+
+## Design — Phantom Token proxy
+
+The container is given a *placeholder* credential (`ANTHROPIC_API_KEY=placeholder`) and a base URL pointing at a host proxy (`ANTHROPIC_BASE_URL=http://:3001`). The proxy strips the placeholder on every request and injects the real credential — API key or OAuth bearer — pulled from the host token store, before forwarding to `api.anthropic.com`.
+
+This is the **Phantom Token Pattern** (same shape as [NanoClaw](https://jonno.nz/posts/nanoclaw-architecture-masterclass-in-doing-less/)'s credential proxy). The container literally never holds the real secret — even a prompt-injection-driven env dump exfiltrates only the string `placeholder`. Secrets live in a plain in-memory object on the proxy, never in `process.env`.
+
+### Request flow — strip and inject
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant CC as claude (in container)
+ participant Proxy as agent-proxy (host:3001)
+ participant TS as Host token store
+ participant API as api.anthropic.com
+
+ CC->>Proxy: POST /v1/messages\nx-api-key: "placeholder"
+ Proxy->>Proxy: strip placeholder header
+ Proxy->>TS: read real credential
+ TS-->>Proxy: API key OR OAuth bearer (refresh if expired)
+ Proxy->>API: POST /v1/messages\nx-api-key: "sk-ant-..." OR Authorization: Bearer ...
+ API-->>Proxy: 200 stream (SSE)
+ Proxy-->>CC: 200 stream (SSE, passthrough)
+```
+
+### Lifecycle & discovery
+
+Stopped by default. The runtime starts the daemon on first sandbox launch, every concurrent sandbox reuses the same instance, and the daemon self-exits 15s after the last keepalive. Discovery is a single file `~/.jaiph/agent-proxy.json` carrying `{pid, address, port}`; the runtime reads it, verifies the PID is alive, and respawns if not.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant R as jaiph runtime
+ participant F as ~/.jaiph/agent-proxy.json
+ participant P as agent-proxy
+ participant CT as sandbox container
+ R->>F: read pid + port
+ alt missing or pid dead
+ R->>P: spawn (port 3001)
+ P->>F: write {pid, address, port}
+ end
+ R->>P: GET /healthz
+ P-->>R: 200 ok
+ R->>CT: launch with ANTHROPIC_BASE_URL=:
+ loop while sandbox alive
+ R->>P: POST /keepalive
+ P-->>R: 204
+ end
+ Note over P: idle 15s → exit, rm agent-proxy.json
+```
+
+### Healthcheck
+
+`GET /healthz` verifies token store reachable, credential valid (or refreshable), `api.anthropic.com` reachable. The runtime calls it before launching the container so auth failures surface up front, not deep inside a model request.
+
+### Cross-platform token source (OAuth mode)
+
+| Platform | Source | Reader |
+|---|---|---|
+| macOS | Keychain item `Claude Code-credentials` | `security find-generic-password -w` |
+| Linux (GNOME/KDE) | libsecret via Secret Service | `secret-tool lookup ...` |
+| Linux (headless) | `~/.claude/.credentials.json` | read file (with refresh) |
+
+> API-key mode skips this entirely — the runtime passes `JAIPH_PROXY_API_KEY` to the daemon at spawn, which loads it into the in-memory `SECRET` object and scrubs `process.env`.
+
+### Cross-platform bind address
+
+`ensure.ts` resolves both sides; the daemon is platform-agnostic.
+
+| Platform | Proxy binds to | Container reaches via |
+|---|---|---|
+| macOS / WSL2 | `127.0.0.1:3001` | `host.docker.internal:3001` (built-in) |
+| Linux | `:3001` | that same IP — resolved at sandbox launch |
+
+## Codebase layout
+
+All new code in one directory under `src/agent-proxy/`; matches the jaiph code philosophy (short files, ≤3 files per feature).
+
+```
+src/agent-proxy/
+├── index.ts # daemon entry: HTTP server, /healthz, /keepalive, idle-exit
+├── secrets.ts # cross-platform credential loader (API key + OAuth)
+└── ensure.ts # runtime-side: read state, spawn if dead, probe, return endpoint
+
+e2e/tests/agent_proxy_*.bats # phantom-token, lifecycle, healthcheck, concurrency...
+src/runtime/docker.ts # one new call site (see Wiring)
+~/.jaiph/agent-proxy.json # discovery state file shared runtime ↔ proxy
+```
+
+### File responsibilities
+
+| File | Runs in | Responsibility | Public API |
+|---|---|---|---|
+| `index.ts` | spawned daemon | HTTP server, header strip + inject, write state file, idle-exit timer | CLI entry (no exports) |
+| `secrets.ts` | spawned daemon | Read credential from Keychain / libsecret / file / env; refresh OAuth on expiry | `loadSecret()`, `refreshIfExpired()` |
+| `ensure.ts` | jaiph runtime (host process) | Compute bind address, read state file, spawn daemon if not alive, probe `/healthz`, return endpoint | `ensureProxy(): Promise<{address, port}>`, `heartbeat()` |
+
+## Reference implementation
+
+Sketch of `src/agent-proxy/index.ts`. Daemon is platform-agnostic; the runtime tells it where to bind via env. Secrets loaded into `SECRET` (never `process.env`); placeholder stripped on every request.
+
+```ts
+// src/agent-proxy/index.ts — daemon entry; started on demand by the runtime
+import http from "node:http";
+import https from "node:https";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { loadSecret } from "./secrets.js";
+
+const STATE = path.join(os.homedir(), ".jaiph", "agent-proxy.json");
+const BIND = process.env.JAIPH_PROXY_BIND || "127.0.0.1";
+const PORT = Number(process.env.JAIPH_PROXY_PORT) || 3001;
+const IDLE_MS = 15_000;
+
+const SECRET = loadSecret(); // { mode: "apiKey" | "oauth", apiKey?, oauthToken? }
+
+function inject(headers) {
+ const h = { ...headers, host: "api.anthropic.com" };
+ delete h["x-api-key"]; // strip placeholder
+ delete h["authorization"];
+ if (SECRET.mode === "apiKey") h["x-api-key"] = SECRET.apiKey;
+ else h["authorization"] = `Bearer ${SECRET.oauthToken}`;
+ return h;
+}
+
+let lastBeat = Date.now();
+
+const server = http.createServer((req, res) => {
+ if (req.url === "/healthz") { return res.end("ok"); }
+ if (req.url === "/keepalive") { lastBeat = Date.now(); res.statusCode = 204; return res.end(); }
+
+ const up = https.request({
+ host: "api.anthropic.com", path: req.url, method: req.method,
+ headers: inject(req.headers),
+ }, upRes => { res.writeHead(upRes.statusCode, upRes.headers); upRes.pipe(res); });
+ req.pipe(up);
+});
+
+server.listen(PORT, BIND, () => {
+ const { address, port } = server.address();
+ fs.mkdirSync(path.dirname(STATE), { recursive: true });
+ fs.writeFileSync(STATE, JSON.stringify({ pid: process.pid, address, port }));
+});
+
+setInterval(() => {
+ if (Date.now() - lastBeat > IDLE_MS) {
+ fs.rmSync(STATE, { force: true });
+ process.exit(0);
+ }
+}, 1000);
+```
+
+> Elided: OAuth refresh, error mapping, request-body re-streaming for retries, libsecret reader.
+
+## End-to-end tests
+
+`e2e/tests/agent_proxy_*.bats`, run as part of `npm run test:e2e`:
+
+- **Phantom token:** assert container env contains only `ANTHROPIC_API_KEY=placeholder`; capture outbound traffic from container, assert real key/token never appears.
+- **Lifecycle:** launching a sandbox spawns the proxy and creates `agent-proxy.json`; stopping heartbeats causes exit + file removal within ~16s.
+- **Concurrency:** two sandboxes launched in parallel share one proxy — port and PID unchanged across both.
+- **Healthcheck:** `/healthz` returns 200 with a valid credential and 503 once the token is revoked / API key cleared.
+- **Auth-mode switch:** proxy started in API-key mode and OAuth mode each pass an end-to-end model call from the container.
+- **Token refresh:** force-expire the OAuth access token — the next request transparently refreshes via the host without the container noticing.
+- **Streaming:** SSE response from `/v1/messages` arrives chunked in the container; no buffering at the proxy.
+- **Platform matrix:** macOS (Keychain + 127.0.0.1), Linux GNOME (libsecret + docker0), Linux headless (file + docker0) all green in CI.
+
+## Wiring into the runtime
+
+`src/runtime/docker.ts` gains one call before container launch and one heartbeat loop alongside the existing sandbox lifecycle. No env allowlist change — `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` already match the `ANTHROPIC_*` prefix.
+
+```ts
+// src/runtime/docker.ts (sketch of the new call sites)
+import { ensureProxy, heartbeat } from "../agent-proxy/ensure.js";
+
+const { address, port } = await ensureProxy(); // spawns daemon if needed
+
+dockerArgs.push(
+ "--env", "ANTHROPIC_API_KEY=placeholder",
+ "--env", `ANTHROPIC_BASE_URL=http://${address}:${port}`,
+);
+
+const beat = setInterval(() => heartbeat(), 5_000); // keepalive while container runs
+container.on("exit", () => clearInterval(beat));
+```
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index a023350a..0301b67c 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -10,7 +10,8 @@ GEM
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
eventmachine (1.2.7)
- ffi (1.17.3)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
forwardable-extended (2.6.0)
http_parser.rb (0.8.1)
i18n (1.14.8)
@@ -67,6 +68,7 @@ GEM
PLATFORMS
arm64-darwin-23
+ arm64-darwin-25
x86_64-linux
DEPENDENCIES
diff --git a/docs/architecture.md b/docs/architecture.md
index 141f5735..8b8a9e2d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -45,14 +45,18 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- **`emitScriptsForModule`** parses, runs **`validateReferences`**, and **`buildScriptFiles`** — the only compile path for `jaiph run` / `jaiph test` — **persists only atomic `script` files** under `scripts/`. Inline scripts (`` run `body`(args) ``) are also emitted as `scripts/__inline_` with deterministic hash-based names. There is no workflow-level bash emission.
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- - `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, emits events, and writes run artifacts.
+ - `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
+ - Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
+ - **`runtime-arg-parser.ts`** — stateless interpolation and call-argument parsing (`interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`) plus shared constants and the `ParsedArgToken` / `PromptSchemaField` types. Direct unit tests live in `runtime-arg-parser.test.ts`.
+ - **`runtime-event-emitter.ts`** — `RuntimeEventEmitter` owns the `__JAIPH_EVENT__` stderr stream and `run_summary.jsonl` writes for workflow/step/prompt/log events, plus the monotonic step and prompt sequence counters. Constructed with `{ runId, runDir, env, getFrameStack, getAsyncIndices, suppressLiveEvents? }`; the runtime delegates all event emission to it. The optional `suppressLiveEvents` flag (forwarded from `NodeWorkflowRuntime`'s `suppressLiveEvents` option) skips the live stderr write while leaving the durable `run_summary.jsonl` append intact — used by in-process callers like the test runner that share stderr with `node --test` reporter output. The CLI's spawned `node-workflow-runner` child does not set it, so production runs stream events to stderr as before.
+ - **`runtime-mock.ts`** — `executeMockBodyDef` and `executeMockShellBody` for `*.test.jh` workflow/rule/script mocks. Shell-kind mocks run `bash -c`; steps-kind mocks dispatch back into the runtime via an `executeStepsBack` callback so the body runs against the full step interpreter.
- `buildRuntimeGraph()` (`graph.ts`) loads reachable modules with **`parsejaiph` only** (import closure); it does **not** run `validateReferences`. Cross-module refs are resolved from that graph at runtime.
- **Node Test Runner (`src/runtime/kernel/node-test-runner.ts`)**
- Executes `*.test.jh` test blocks using `NodeWorkflowRuntime` with mock support (mock prompts, mock workflow/rule/script bodies). Pure Node harness — no Bash test transpilation.
- **JS kernel (`src/runtime/kernel/`)**
- - Prompt execution (`prompt.ts`), managed subprocess execution (`run-step-exec.ts`), streaming parse (`stream-parser.ts`), schema (`schema.ts`), mocks (`mock.ts`), **`emit.ts`** (live `__JAIPH_EVENT__` + `run_summary.jsonl`), **`workflow-launch.ts`** (spawn contract).
+ - Prompt execution (`prompt.ts`), streaming parse (`stream-parser.ts`), schema (`schema.ts`), mocks (`mock.ts`), **`emit.ts`** (live `__JAIPH_EVENT__` + `run_summary.jsonl`), **`workflow-launch.ts`** (spawn contract). Script subprocesses are launched directly from `NodeWorkflowRuntime`.
- **Formatter (`src/format/emit.ts`)**
- `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. Pure AST→text emitter; no side-effects beyond file writes.
@@ -103,7 +107,7 @@ The runtime persists step captures and the event timeline under a UTC-dated hier
run_summary.jsonl # durable event timeline
```
-Step sequence numbers are monotonic and unique per run: `NodeWorkflowRuntime` allocates them in memory when opening each step’s capture files (`%06d-.out|.err`). The standalone module `kernel/seq-alloc.ts` is a **file-backed** allocator (and CLI `node seq-alloc.js`) for tooling or non-kernel callers; the Node workflow runtime does **not** rely on a `.seq` file in the run directory for ordinary execution.
+Step sequence numbers are monotonic and unique per run: `NodeWorkflowRuntime` allocates them in memory when opening each step’s capture files (`%06d-.out|.err`). There is no `.seq` file in the run directory.
## Channels and hooks in context
@@ -111,7 +115,7 @@ Channels are validated at compile time (`validateReferences` / send RHS rules) a
## Test runner integration (`*.test.jh` in the kernel)
-**How** `jaiph test` wires into the same stack as `jaiph run`: `*.test.jh` files are parsed in the CLI; `runTestFile()` drives blocks in-process. **`buildRuntimeGraph(testFile)`** is called **once per `runTestFile` invocation** and the resulting graph is reused across all blocks and `test_run_workflow` steps (the import closure is constant for a given test file within a single process run). Each `test_run_workflow` step resolves mocks against that cached graph, then constructs `NodeWorkflowRuntime` with `mockBodies` / mock prompt env. Mock prompts, workflows, rules, and scripts are supported through the runtime's mock infrastructure.
+**How** `jaiph test` wires into the same stack as `jaiph run`: `*.test.jh` files are parsed in the CLI; `runTestFile()` drives blocks in-process. **`buildRuntimeGraph(testFile)`** is called **once per `runTestFile` invocation** and the resulting graph is reused across all blocks and `test_run_workflow` steps (the import closure is constant for a given test file within a single process run). Each `test_run_workflow` step resolves mocks against that cached graph, then constructs `NodeWorkflowRuntime` with `mockBodies` / mock prompt env, passing **`suppressLiveEvents: true`** so the in-process runtime's `__JAIPH_EVENT__` stderr writes are skipped (durable `run_summary.jsonl` writes are unaffected). Without this flag, every workflow event would print to the test process's stderr and swamp `node --test` reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime's mock infrastructure.
Before that, the CLI prepares script executables via **`buildScripts(testFileAbs, tmpDir, workspaceRoot)`** — the same **`buildScripts`** helper as `jaiph run`, with the **test file as the entrypoint**. That walks the test module and its **import closure** (transitive `import` edges), runs **`validateReferences`** / **`emitScriptsForModule`** per reachable file, and writes `scripts/` so imported workflows have paths under `JAIPH_SCRIPTS`. Unrelated `*.jh` files elsewhere in the repo are not compiled unless imported.
Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Testing](testing.md), not here.
diff --git a/docs/cli.md b/docs/cli.md
index 132aabde..ddd7c6a0 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -163,7 +163,7 @@ log response
### Failed run summary (stderr)
-On non-zero exit, the CLI may print a footer with the path to `run_summary.jsonl`, `out:` / `err:` artifact paths, and `Output of failed step:` plus a trimmed excerpt. These are resolved from the **first** `STEP_END` object in the summary with `status` != 0, using `out_content` / `err_content` when present and otherwise the `out_file` / `err_file` fields. If no failed `STEP_END` is found, the CLI falls back to a run-directory artifact heuristic.
+On non-zero exit, the CLI may print a footer with the path to `run_summary.jsonl`, `out:` / `err:` artifact paths, and `Output of failed step:` plus a trimmed excerpt. These are resolved from the **last** `STEP_END` object in the summary with `status` != 0, using `out_content` / `err_content` when present and otherwise the `out_file` / `err_file` fields (last matches terminal failure after `catch`/`ensure` retries and stray earlier failures). If no failed `STEP_END` is found, the CLI falls back to a run-directory artifact heuristic.
In Docker mode, artifact paths recorded by the container use container-internal prefixes (`/jaiph/run/…`). The CLI remaps these to host paths and discovers the run directory from the bind-mounted runs directory by matching the `JAIPH_RUN_ID` in each `run_summary.jsonl` when the container meta file is inaccessible. This run-id-based lookup is safe under concurrent `jaiph run` invocations sharing the same runs directory. The failure summary therefore displays identically to local (no-sandbox) runs — same structure, same host-resolvable paths, same "Output of failed step" excerpt. See [Sandboxing — Path remapping](sandboxing.md#path-remapping).
@@ -185,7 +185,7 @@ If a stream stays empty for a step, the runtime may omit that artifact file. Any
### Run summary (`run_summary.jsonl`) {#run-summary-jsonl}
-Each run directory also contains `run_summary.jsonl`: one JSON object per line, appended in execution order. It is the canonical append-only record of runtime events (lifecycle, logs, inbox flow, and step boundaries). Tooling can tail the file by byte offset and process new lines idempotently; parallel inbox dispatch may reorder some events relative to wall-clock time, but each line is written atomically under the same lock used for concurrent writers (see [Inbox — Lock behavior](inbox.md#lock-behavior)).
+Each run directory also contains `run_summary.jsonl`: one JSON object per line, appended in execution order. It is the canonical append-only record of runtime events (lifecycle, logs, inbox flow, and step boundaries). Tooling can tail the file by byte offset and process new lines idempotently. For a single run, lines follow execution order; inbox routes always drain **sequentially**, so inbox lifecycle events stay aligned with dispatch order. Summary lines are still appended atomically under a lock shared with other concurrent writers on the same run directory (for example `run async` branches appending step events).
**Versioning.** Every object includes `event_version` (currently `1`). New fields may be added; consumers should tolerate unknown keys.
@@ -194,10 +194,10 @@ Each run directory also contains `run_summary.jsonl`: one JSON object per line,
**Correlation rules:**
- **`run_id`:** same across all lines in a given run's file.
-- **Workflow boundaries:** for each workflow name, `WORKFLOW_START` count equals `WORKFLOW_END` count. With `JAIPH_INBOX_PARALLEL=true`, lifecycle lines may interleave — use per-name counts, not a global stack.
+- **Workflow boundaries:** for each workflow name, `WORKFLOW_START` count equals `WORKFLOW_END` count.
- **Steps:** `STEP_START` and `STEP_END` share the same `id`. Use `parent_id`, `seq`, and `depth` to rebuild the tree.
- **Inbox:** one `INBOX_ENQUEUE` per `send` with a unique `inbox_seq` (zero-padded, e.g. `001`). Each routed target gets one `INBOX_DISPATCH_START` and one `INBOX_DISPATCH_COMPLETE` sharing the same `inbox_seq`, `channel`, `target`, and `sender`.
-- **Ordering under parallel inbox:** lines are valid JSONL (one object per line, atomic append). Wall-clock `ts` order may diverge from append order between concurrent branches.
+- **Ordering:** lines are valid JSONL (one object per line, atomic append). Inbox dispatch is sequential; `ts` order matches dispatch order for inbox lifecycle events on a single run.
**Event taxonomy (schema `event_version` 1):**
@@ -432,7 +432,6 @@ These variables apply to `jaiph run` and workflow execution. Variables marked **
- `JAIPH_DEBUG` — set to `true` for debug tracing.
- `JAIPH_RECURSION_DEPTH_LIMIT` — maximum recursion depth for workflows and rules (default: **256**). Exceeding this limit produces a runtime error.
-- `JAIPH_INBOX_PARALLEL` — set to `true` for parallel dispatch of inbox route targets (overrides in-file `run.inbox_parallel`). See [Inbox](inbox.md).
- `NO_COLOR` — disables colored output.
**Non-TTY heartbeat:**
diff --git a/docs/configuration.md b/docs/configuration.md
index edce69b0..85dbf640 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -7,7 +7,7 @@ redirect_from:
# Configuration
-When you need the same workflow sources to behave differently on different machines, you separate **what the graph does** (rules, `prompt` / `script` / `run`, channels) from **operational knobs**: which LLM backend to use, where to write run logs, how inbox dispatch behaves, and how the CLI chooses host vs. Docker. Jaiph keeps the language stable and pushes those choices into **configuration** — in-file `config` blocks, environment variables, and defaults in the tool.
+When you need the same workflow sources to behave differently on different machines, you separate **what the graph does** (rules, `prompt` / `script` / `run`, channels) from **operational knobs**: which LLM backend to use, where to write run logs and debug output, and how the CLI chooses host vs. Docker. Jaiph keeps the language stable and pushes those choices into **configuration** — in-file `config` blocks, environment variables, and defaults in the tool. Inbox dispatch order is defined by the language (sequential drain of route targets — see [Inbox & Dispatch](inbox.md)); it is not a configuration toggle.
All execution is interpreted by the Node workflow runtime (`NodeWorkflowRuntime`): the AST, managed scripts, prompts, channels, inbox, and `.jaiph/runs` artifacts (see [Architecture](architecture.md)). Configuration only adjusts that stack; it does not change the workflow language or the compile graph.
@@ -19,7 +19,7 @@ All execution is interpreted by the Node workflow runtime (`NodeWorkflowRuntime`
Jaiph provides three configuration mechanisms. When the same key is set in more than one place, the highest-priority source wins:
-1. **Environment variables** — highest priority. Includes `JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`, `JAIPH_INBOX_PARALLEL`, `JAIPH_DOCKER_ENABLED`, other `JAIPH_DOCKER_*`, and `JAIPH_UNSAFE` (for Docker on/off, see [Sandboxing — Enabling Docker](sandboxing.md#enabling-docker)). Docker **enablement** is only controlled here — there is no `runtime.*` in-file key for that (removed; using it is a parse error with a migration message).
+1. **Environment variables** — highest priority. Includes `JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`, `JAIPH_DOCKER_ENABLED`, other `JAIPH_DOCKER_*`, and `JAIPH_UNSAFE` (for Docker on/off, see [Sandboxing — Enabling Docker](sandboxing.md#enabling-docker)). Docker **enablement** is only controlled here — there is no `runtime.*` in-file key for that (removed; using it is a parse error with a migration message).
2. **In-file `config { ... }` blocks** — at module scope and optionally inside a `workflow` body.
3. **Built-in defaults** — lowest priority, used when nothing else sets a value.
@@ -134,7 +134,6 @@ These control runtime behavior unrelated to the agent.
|-----|------|---------|--------------|-------------|
| `run.logs_dir` | string | `.jaiph/runs` | `JAIPH_RUNS_DIR` | Step log directory. Relative paths are joined with the workspace root; absolute paths are used as-is. |
| `run.debug` | boolean | `false` | `JAIPH_DEBUG` | Enables debug tracing for the run. |
-| `run.inbox_parallel` | boolean | `false` | `JAIPH_INBOX_PARALLEL` | Dispatch inbox route targets concurrently. See [Inbox — Parallel dispatch](inbox.md#parallel-dispatch). |
| `run.recover_limit` | integer | `10` | _(no env override)_ | Maximum number of retry attempts for `run … recover` loops before the step fails. See [Language — `recover`](language.md#recover--repair-and-retry-loop). |
### Module keys
@@ -180,7 +179,7 @@ These configure Docker sandboxing. Unlike agent and run keys, runtime keys are r
For **agent and run keys**, resolution order (highest wins):
-1. **Environment** — `JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`, `JAIPH_INBOX_PARALLEL`. When set, these lock the value for the entire process (see [Locked variables](#locked-variables)).
+1. **Environment** — `JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`. When set, these lock the value for the entire process (see [Locked variables](#locked-variables)).
2. **Workflow-level `config`** — overrides module values for the duration of that workflow.
3. **Module-level `config`** — applies to workflows that don't define their own block.
4. **Built-in defaults.**
@@ -191,7 +190,7 @@ For **Docker enablement**, the `jaiph run` driver uses **`JAIPH_DOCKER_ENABLED`
When `jaiph run` builds the runner environment, any of these environment variables already present in `process.env` gets a matching `${NAME}_LOCKED` flag set to `"1"`:
-`JAIPH_AGENT_MODEL`, `JAIPH_AGENT_COMMAND`, `JAIPH_AGENT_BACKEND`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_AGENT_CURSOR_FLAGS`, `JAIPH_AGENT_CLAUDE_FLAGS`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`, `JAIPH_INBOX_PARALLEL`
+`JAIPH_AGENT_MODEL`, `JAIPH_AGENT_COMMAND`, `JAIPH_AGENT_BACKEND`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_AGENT_CURSOR_FLAGS`, `JAIPH_AGENT_CLAUDE_FLAGS`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`
Locked values cannot be overridden by module-level or workflow-level config — they are authoritative for the entire process. This is how environment variables always win in the precedence chain.
@@ -320,7 +319,6 @@ Quick reference for all in-file keys and their environment variable equivalents:
| `agent.claude_flags` | `JAIPH_AGENT_CLAUDE_FLAGS` |
| `run.logs_dir` | `JAIPH_RUNS_DIR` |
| `run.debug` | `JAIPH_DEBUG` |
-| `run.inbox_parallel` | `JAIPH_INBOX_PARALLEL` |
| `run.recover_limit` | _(no env override)_ |
| `runtime.docker_image` | `JAIPH_DOCKER_IMAGE` |
| `runtime.docker_network` | `JAIPH_DOCKER_NETWORK` |
diff --git a/docs/contributing.md b/docs/contributing.md
index 589848b5..a53f3fac 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -48,14 +48,14 @@ For day-to-day work on the compiler and CLI you usually stay inside the clone: i
| `npm install` | Installs TypeScript and types (dev dependencies). |
| `npm run build` | Runs `tsc`, then copies **`src/runtime`** → **`dist/src/runtime`** (kernel JS for the compiled CLI) and **`runtime/overlay-run.sh`** → **`dist/src/runtime/overlay-run.sh`** (Docker overlay entrypoint). |
| `npm run build:standalone` | `npm run build`, then copies **`dist/src/runtime`** → **`dist/runtime`** and runs **`bun build --compile`** on `src/cli.ts` → **`dist/jaiph`**. Requires [Bun](https://bun.sh). Ship the **`dist/`** tree (binary plus the runtime directory) for a self-contained layout. |
-| `npm test` | **`npm run clean`**, then **`npm run build`**, then the Node.js test runner with **`JAIPH_UNSAFE=true`**, **`NODE_OPTIONS`** including **`--enable-source-maps`** and a large heap limit, on `dist/test/*.test.js`, every file under `dist/src/` matching `*.test.js` or `*.acceptance.test.js` (via `find`), `dist/src/compiler-test-runner.js` (txtar compiler tests), and `dist/src/golden-ast-runner.js` (golden AST tests). |
-| `npm run test:compiler` | **`npm run build`**, then **`node --test`** on `dist/src/compiler-test-runner.js` — runs txtar-based compiler test fixtures from `compiler-tests/`. |
-| `npm run test:golden-ast` | **`npm run build`**, then **`node --test`** on `dist/src/golden-ast-runner.js` — runs golden AST tests from `golden-ast/`. Use `UPDATE_GOLDEN=1 npm run test:golden-ast` to regenerate goldens after intentional parser changes. |
+| `npm test` | **`npm run clean`**, then **`npm run build`**, then the Node.js test runner with **`JAIPH_UNSAFE=true`**, **`NODE_OPTIONS`** including **`--enable-source-maps`** and a large heap limit, on every file under `dist/integration/` matching `*.test.js`, every file under `dist/src/` matching `*.test.js` or `*.acceptance.test.js` (via `find`), `dist/test-infra/compiler-test-runner.js` (txtar compiler tests), and `dist/test-infra/golden-ast-runner.js` (golden AST tests). |
+| `npm run test:compiler` | **`npm run build`**, then **`node --test`** on `dist/test-infra/compiler-test-runner.js` — runs txtar-based compiler test fixtures from `test-fixtures/compiler-txtar/`. |
+| `npm run test:golden-ast` | **`npm run build`**, then **`node --test`** on `dist/test-infra/golden-ast-runner.js` — runs golden AST tests from `test-fixtures/golden-ast/`. Use `UPDATE_GOLDEN=1 npm run test:golden-ast` to regenerate goldens after intentional parser changes. |
| `npm run test:acceptance:compiler` | **`npm run build`**, then **`node --test`** on only `dist/src/**/*.acceptance.test.js` — compiler acceptance tests without the full unit suite or E2E. |
| `npm run test:acceptance:runtime` | **`bash ./e2e/test_all.sh`** only — same E2E driver as below **without** an implicit rebuild; ensure `dist/` is up to date before running. |
| `npm run test:acceptance` | **`npm run test:acceptance:compiler`** then **`npm run test:acceptance:runtime`**. |
| `npm run test:e2e` | **`npm run build`**, then **`bash ./e2e/test_all.sh`**. Prefer this when you want a fresh `dist/` before E2E. By default this exercises the **Docker** sandbox when `JAIPH_UNSAFE` is unset. For a faster host-only run (no container), use **`JAIPH_UNSAFE=true npm run test:e2e`**. |
-| `npm run test:samples` | **`npx playwright test`** — Playwright suite for the docs landing page (`tests/e2e-samples/`). Uses `http://127.0.0.1:4000` (see `playwright.config.ts`); starts Jekyll via `webServer` or reuses one already on that port. Requires Playwright (`npx playwright install chromium` once). |
+| `npm run test:samples` | **`npx playwright test`** — Playwright suite for the docs landing page (`e2e/playwright/`). Uses `http://127.0.0.1:4000` (see `playwright.config.ts`); starts Jekyll via `webServer` or reuses one already on that port. Requires Playwright (`npx playwright install chromium` once). |
| `npm run test:ci` | `npm test` followed by `npm run test:e2e` — useful before pushing when you want the full local picture. |
Run a single Node test file after a build with e.g. `node --test dist/src/parse/parse-core.test.js`. The `dist/` paths mirror the source layout under `src/`.
@@ -98,9 +98,9 @@ Jaiph uses several test layers. Each layer catches a different class of bug. Use
| **Module tests** | `src/**/*.test.ts` (colocated) | Bugs in pure functions (event parsing, param formatting, path resolution, config merging) | The function is self-contained, takes input and returns output, no I/O |
| **Compiler acceptance tests** | `src/transpile/*.acceptance.test.ts` (colocated) | Cross-module compiler behavior: validation errors, resolution, and other cases that need a temp project tree or subprocess | You need a deterministic error string, multi-file `buildScripts`, or behavior that does not fit a tiny golden snippet |
| **Compiler golden tests** | `src/transpile/compiler-golden.test.ts` (colocated) | Regressions in the parser, validation messages, and scripts-only extraction (`buildScriptFiles` in `emit-script.ts`) — expectations are inline in the test file | You changed the parser, validator, or script extraction and need to lock an exact error string, extracted script shape, or corpus behavior |
-| **Compiler tests (txtar)** | `compiler-tests/*.txt` | Parse and validate outcomes — success, parse errors, validation errors — using language-agnostic txtar fixtures (hundreds of `===` cases across the four `*.txt` files) | You want a portable test case that can be reused by alternative compiler implementations; the test is a `.jh` input paired with an expected outcome |
-| **Golden AST tests** | `golden-ast/fixtures/*.jh` + `golden-ast/expected/*.json` | Parse tree shape for successful parses — serialized to deterministic JSON with locations stripped (9 fixtures: e.g. imports, brace-if, log, match and match-multiline, params, prompt-capture, run-ensure, script-defs) | You changed the parser and need to verify the AST structure hasn't drifted; txtar tests only check pass/fail, goldens lock in the actual tree shape |
-| **Cross-cutting tests** | `test/*.test.ts` | Process-level integration behavior: signal handling, TTY rendering, run summary structure, sample builds | The test spans multiple modules or requires subprocess/PTY harnesses |
+| **Compiler tests (txtar)** | `test-fixtures/compiler-txtar/*.txt` | Parse and validate outcomes — success, parse errors, validation errors — using language-agnostic txtar fixtures (hundreds of `===` cases across the four `*.txt` files) | You want a portable test case that can be reused by alternative compiler implementations; the test is a `.jh` input paired with an expected outcome |
+| **Golden AST tests** | `test-fixtures/golden-ast/fixtures/*.jh` + `test-fixtures/golden-ast/expected/*.json` | Parse tree shape for successful parses — serialized to deterministic JSON with locations stripped (9 fixtures: e.g. imports, brace-if, log, match and match-multiline, params, prompt-capture, run-ensure, script-defs) | You changed the parser and need to verify the AST structure hasn't drifted; txtar tests only check pass/fail, goldens lock in the actual tree shape |
+| **Integration tests** | `integration/*.test.ts`, `integration/sample-build/*.test.ts` | Process-level integration behavior: signal handling, TTY rendering, run summary structure, sample builds | The test spans multiple modules or requires subprocess/PTY harnesses |
| **E2E tests** | `e2e/tests/*.sh` | Runtime behavior — does the workflow actually execute correctly end-to-end? | The behavior involves the CLI launcher, Node runtime, process lifecycle, or file artifacts |
### Key principles
@@ -108,16 +108,16 @@ Jaiph uses several test layers. Each layer catches a different class of bug. Use
1. **Compile-time validation vs graph loading.** `buildScripts` / `emitScriptsForModule` run **`validateReferences`** before any script files are written. **`buildRuntimeGraph()`** only parses modules and follows imports — it does **not** re-run that validation. Lock compile errors in the compiler/validator tests; the runtime graph is the wrong layer for that (see [Architecture — Transpiler / Node workflow runtime](architecture.md#core-components)).
2. **Tests are behavior contracts.** E2E tests and acceptance tests define what the product does. Default approach: change production code to satisfy tests, not the other way around.
3. **Modify existing tests only with a strong reason:** intentional product behavior change, incorrect test expectation, or removal of an obsolete feature. Any such change should be minimal and paired with a clear rationale.
-4. **Golden tests are the compiler's safety net.** After transpiler changes, run `npm test`. Failures in `src/transpile/compiler-golden.test.ts` usually mean updating an explicit expected string or fixture in that file — there is no separate dump script; align expectations with intentional emitter changes and re-run `npm test`. **Golden AST tests** (`golden-ast/`) complement this by locking in the parse tree shape — if those fail, regenerate with `UPDATE_GOLDEN=1 npm run test:golden-ast` and review the diff.
+4. **Golden tests are the compiler's safety net.** After transpiler changes, run `npm test`. Failures in `src/transpile/compiler-golden.test.ts` usually mean updating an explicit expected string or fixture in that file — there is no separate dump script; align expectations with intentional emitter changes and re-run `npm test`. **Golden AST tests** (`test-fixtures/golden-ast/`) complement this by locking in the parse tree shape — if those fail, regenerate with `UPDATE_GOLDEN=1 npm run test:golden-ast` and review the diff.
5. **E2E tests assert two things independently:** what the user sees (CLI tree output via `e2e::expect_stdout`) and what the runtime persists (artifact files via `e2e::expect_out`, `e2e::expect_file`). A bug could break one without the other.
6. **Prefer the narrowest test layer.** A pure function bug should be caught by a unit test, not an E2E test. E2E tests are expensive to run and hard to debug — reserve them for integration-level behavior.
### TypeScript test layout
- **Module tests** — live next to the source they validate under `src/` (e.g. `src/parse/parse-core.test.ts`, `src/cli/run/display.test.ts`, `src/transpile/compiler-golden.test.ts`). Names are `*.test.ts` or `*.acceptance.test.ts`.
-- **Cross-cutting tests** — span multiple modules or need subprocess/PTY harnesses; they stay in `test/` (see [Cross-cutting tests in `test/`](#cross-cutting-tests-in-test)).
+- **Integration tests** — span multiple modules or need subprocess/PTY harnesses; they live in `integration/` (see [Integration tests](#integration-tests)).
- **E2E** — bash scripts in `e2e/tests/*.sh`, driven by `e2e/test_all.sh`.
-- **`npm test`** discovers colocated files under `src/` and everything in `test/`; see the [Developing in the repository](#developing-in-the-repository) table for the exact command.
+- **`npm test`** discovers colocated files under `src/`, integration tests under `integration/`, and test infrastructure in `test-infra/`; see the [Developing in the repository](#developing-in-the-repository) table for the exact command.
### Module test layout (colocated)
@@ -140,18 +140,24 @@ find src -type f \( -name '*.test.ts' -o -name '*.acceptance.test.ts' \) | sort
When adding a new source module or extending an existing one, create or extend the corresponding `*.test.ts` in the same directory. For kernel internals, the compile path, and artifact contracts, see [Architecture](architecture.md).
-### Cross-cutting tests in `test/`
+### Integration tests
-Tests that span multiple modules, require subprocess/PTY harnesses, or exercise process-level behavior remain in `test/`. These do not belong to a single module:
+Tests that span multiple modules, require subprocess/PTY harnesses, or exercise process-level behavior live in `integration/`. These do not belong to a single module:
| Test file | Kind | What it covers |
|-----------|------|----------------|
-| `sample-build.test.ts` | Integration | Cross-module build/transpile/run-tree behavior using real compiler and CLI components |
-| `run-summary-jsonl.test.ts` | Integration | Runs the CLI on a small workflow and asserts structure and fields of `run_summary.jsonl` under `.jaiph/runs/` |
-| `signal-lifecycle.test.ts` | Acceptance | After SIGINT/SIGTERM, verifies `jaiph run` exits within a time bound and leaves no stale child processes |
-| `tty-running-timer.test.ts` | Acceptance | In a TTY, verifies the “RUNNING workflow” line updates over time (requires Python 3 PTY harness) |
-
-Shared test data (`test/fixtures/`, `test/expected/`) also remains in `test/`.
+| `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, `buildScriptFiles`, script extraction |
+| `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows |
+| `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts |
+| `integration/sample-build/run-prompt-agent.test.ts` | Integration | Prompt and agent interaction in sample workflows |
+| `integration/sample-build/recover-handle.test.ts` | Integration | `recover` / `Handle` async behavior in sample workflows |
+| `integration/sample-build/test-advanced.test.ts` | Integration | Advanced test harness behavior — mocks, channels, edge cases |
+| `integration/sample-build/test-framework.test.ts` | Integration | Test framework basics — `mock prompt`, `expect_*`, test block lifecycle |
+| `integration/run-summary-jsonl.test.ts` | Integration | Runs the CLI on a small workflow and asserts structure and fields of `run_summary.jsonl` under `.jaiph/runs/` |
+| `integration/signal-lifecycle.test.ts` | Acceptance | After SIGINT/SIGTERM, verifies `jaiph run` exits within a time bound and leaves no stale child processes |
+| `integration/tty-running-timer.test.ts` | Acceptance | In a TTY, verifies the “RUNNING workflow” line updates over time (requires Python 3 PTY harness) |
+
+The `integration/sample-build/` directory also has a shared `helpers.ts` module used by the sample-build tests. Shared test fixtures (`.jh` source files and expected output) live in `test-fixtures/sample-build/`.
## CI pipeline
@@ -188,7 +194,7 @@ The Jekyll project lives entirely inside `docs/` — `Gemfile`, `_config.yml`, l
### Landing-page sample verification (Playwright)
-After the Jekyll smoke-check, the CI job also verifies that code samples shown on the landing page match real CLI behavior. This uses Playwright (Chromium) with a test suite in `tests/e2e-samples/landing-page.spec.ts`.
+After the Jekyll smoke-check, the CI job also verifies that code samples shown on the landing page match real CLI behavior. This uses Playwright (Chromium) with a test suite in `e2e/playwright/landing-page.spec.ts`.
The test does two things:
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 6b923a82..b1620d34 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -9,9 +9,9 @@ redirect_from:
## Overview
-**Jaiph** is a workflow system for building agent-style pipelines. You write `.jh` sources (and optional `*.test.jh` test modules) that combine **prompts**, **rules**, **scripts**, and **workflows**. The project ships a **TypeScript CLI** and a **JavaScript kernel** under the Node workflow runtime: the same AST is **parsed and validated** at prepare time, **script** bodies are written as files under `scripts/`, and **execution** is direct AST interpretation in process—there is no separate workflow shell binary (see [Architecture](architecture.md) for boundaries, pipelines, and contracts such as `__JAIPH_EVENT__` and `.jaiph/runs/`).
+**Jaiph** is a workflow system for building agent-style pipelines. You write `.jh` sources (and optional `*.test.jh` test modules) that combine **prompts**, **rules**, **scripts**, and **workflows**.
-This page is a **map**: it does not teach syntax end-to-end; it points to install steps, language references, and runtime behavior.
+This page is a **map**: it does not teach syntax end-to-end; it points to install steps, language references, and runtime behavior. For how the tool fits together, see [Architecture](architecture.md).
## Setup
diff --git a/docs/grammar.md b/docs/grammar.md
index 3ae513c4..c4c1f140 100644
--- a/docs/grammar.md
+++ b/docs/grammar.md
@@ -904,7 +904,7 @@ config_block = "config" "{" { config_line } "}" ;
config_line = config_key "=" config_value ;
config_key = "agent.default_model" | "agent.command" | "agent.backend" | "agent.trusted_workspace"
| "agent.cursor_flags" | "agent.claude_flags" | "run.logs_dir" | "run.debug"
- | "run.inbox_parallel" | "run.recover_limit" | "runtime.docker_image" | "runtime.docker_network"
+ | "run.recover_limit" | "runtime.docker_image" | "runtime.docker_network"
| "runtime.docker_timeout_seconds"
| "module.name" | "module.version" | "module.description" ;
config_value = string | "true" | "false" | integer | string_array ;
diff --git a/docs/inbox.md b/docs/inbox.md
index ac762d96..7cea5fd9 100644
--- a/docs/inbox.md
+++ b/docs/inbox.md
@@ -60,9 +60,8 @@ channel name, and sender bound to its declared parameters `message`, `chan`, and
- **Inbox is an event bus, not a filesystem watcher.** Delivery is driven by an
explicit **drain** after the orchestrator workflow's steps finish — no
`inotifywait`, no `fswatch`, no polling for new files.
-- **Sequential by default, parallel opt-in.** For each queued message, route
- targets run **in list order** unless `run.inbox_parallel = true` or
- `JAIPH_INBOX_PARALLEL=true` (see [Parallel dispatch](#parallel-dispatch)).
+- **Sequential dispatch.** For each queued message, route targets run **in list
+ order** (declaration order on the `channel` line), one completion at a time.
- **Inbox is scoped per run.** Message files live under that run's **`inbox/`**
directory; they are not a separate mailbox outside `.jaiph/runs`.
- **Channels are compile-checked.** Unknown channels, bad route targets, and
@@ -168,8 +167,7 @@ workflow default() {
```
**Multiple targets on one declaration** are comma-separated — they share one
-route and dispatch in **declaration order** (or concurrently when parallel
-dispatch is on):
+route and dispatch in **declaration order**, sequentially:
```jh
channel findings -> analyst, reviewer
@@ -251,11 +249,9 @@ handling and `drainWorkflowQueue`.
same queue and are processed in subsequent iterations.
- For each message, look up targets for `channel` on **that** workflow's
context. If there is no route, **skip** (silent drop).
- - If there are targets, invoke each target, binding message, channel, and
- sender to the target's 3 declared parameters — **sequentially** in
- target-list order by default, or **all targets concurrently** via
- `Promise.all` when `JAIPH_INBOX_PARALLEL=true`
- (see [Ordering guarantees](#ordering-guarantees)).
+ - If there are targets, invoke each target **sequentially** in target-list
+ order, binding message, channel, and sender to the target's 3 declared
+ parameters (see [Ordering and sequence ids](#ordering-and-sequence-ids)).
6. Pop the workflow context and return.
There is no `E_DISPATCH_DEPTH` / `JAIPH_INBOX_MAX_DISPATCH_DEPTH` check in
@@ -269,61 +265,21 @@ There is no `E_DISPATCH_DEPTH` / `JAIPH_INBOX_MAX_DISPATCH_DEPTH` check in
- **Sender identity** is the **current workflow name** from the context that
performed the send (e.g. `researcher`), stable across modules.
-## Parallel dispatch
+### Ordering and sequence ids
-When `run.inbox_parallel = true` is set in a `config` block (module or workflow
-scope) or the environment sets `JAIPH_INBOX_PARALLEL=true`, **all targets listed
-for a single message** are dispatched concurrently (via `Promise.all` in
-`drainWorkflowQueue`) instead of awaiting each target in order.
+Messages are handled **one at a time** in queue order (FIFO). For each message,
+targets run **strictly in list order** on the `channel` line; the next message is
+not processed until all targets for the current message have finished (success, or
+fail-fast on the first non-zero exit).
-**Precedence** matches the rest of Jaiph agent/run settings: an explicit
-environment value wins over in-file config. See [Configuration — Defaults and precedence](configuration.md#defaults-and-precedence).
-
-```jh
-config {
- run.inbox_parallel = true
-}
-
-channel findings -> analyst, reviewer # analyst and reviewer run in parallel
-
-workflow default() {
- run producer()
-}
-```
-
-### Ordering guarantees
-
-Messages are handled **one at a time** in queue order (FIFO). **Parallel mode**
-only parallelizes **targets for the same message**; the next message is not
-started until the current message's targets have all finished (`Promise.all`
-completes). Within **sequential** mode, targets for that message run strictly in
-list order.
-
-- **Non-determinism:** With `JAIPH_INBOX_PARALLEL=true`, the order in which
- concurrent targets finish is undefined; only the per-message barrier is
- guaranteed before the next message runs.
- **Sequence ids:** Monotonic per run in the runtime (`inboxSeq`); message
filenames use the same padded counter.
-### Failure propagation
-
-In parallel mode, all targets for a message are awaited together. If any target
-exits non-zero, the owning workflow fails after all concurrent targets complete
-(analogous to `Promise.all` failure semantics vs sequential fail-fast).
-
-### Rollback
-
-To revert to sequential dispatch, remove `run.inbox_parallel = true` from config
-or set `JAIPH_INBOX_PARALLEL=false` in the environment. Sequential mode is the
-default.
-
## Error semantics
- **Undefined channel reference:** validation error `Channel "" is not defined`.
-- **Dispatched workflow exits non-zero:** the owning workflow fails. In
- **sequential** mode the first failing target stops further targets for that
- message. In **parallel** mode all targets for that message are awaited, then
- the run fails if any failed.
+- **Dispatched workflow exits non-zero:** the owning workflow fails; the first
+ failing target stops further targets for that message (fail-fast).
- **No route for a channel:** the message file and queue entry still exist, but
dispatch **skips** that message (silent drop). This is intentional for optional
subscribers; use a dedicated workflow if missing handlers should be an error.
@@ -340,17 +296,17 @@ Routed receivers get three dispatch values bound to their declared parameters:
| 2nd declared parameter | Channel name (e.g. `findings`) |
| 3rd declared parameter | Sender name (the **workflow name** that performed the send) |
-The environment variables `JAIPH_DISPATCH_CHANNEL` and `JAIPH_DISPATCH_SENDER`
-are **not** set by `NodeWorkflowRuntime`; receivers get channel and sender via
-their declared parameter names.
+Receivers get channel and sender via their declared parameter names —
+no environment-variable plumbing.
- **`run_summary.jsonl`:** `NodeWorkflowRuntime` appends `INBOX_ENQUEUE`,
`INBOX_DISPATCH_START`, and `INBOX_DISPATCH_COMPLETE` via
`appendRunSummaryLine` (see [CLI — Run summary](cli.md#run-summary-jsonl)).
`INBOX_DISPATCH_COMPLETE` includes `elapsed_ms`. For `INBOX_ENQUEUE`
from `jaiph run`, the line includes `channel`, `sender`, and
- `inbox_seq`. The full message body is always available on disk at
- `inbox/NNN-.txt`.
+ `inbox_seq`. When a route consumes the channel, the full message body
+ is also written to `inbox/NNN-.txt` for audit; sends to
+ unrouted channels stay in the JSONL summary only.
- **Calling a receiver with explicit args:** the CLI’s `jaiph run` only starts
the file’s `default` workflow; extra CLI arguments are passed to `default`
(see [CLI — `jaiph run`](cli.md#jaiph-run)). There is no `jaiph run
diff --git a/docs/install b/docs/install
index 85815f8f..a6be8c1a 100755
--- a/docs/install
+++ b/docs/install
@@ -108,6 +108,7 @@ print_step "Installing runtime to ${LIB_DIR}..."
rm -rf "${LIB_DIR}"
mkdir -p "${LIB_DIR}"
cp -R "${tmp_dir}/src/dist/src" "${LIB_DIR}/src"
+cp "${tmp_dir}/src/package.json" "${LIB_DIR}/package.json"
cp "${tmp_dir}/src/docs/jaiph-skill.md" "${SKILL_TARGET}"
print_step "Installing binary to ${TARGET}..."
diff --git a/docs/jaiph-skill.md b/docs/jaiph-skill.md
index e0104001..a92166d6 100644
--- a/docs/jaiph-skill.md
+++ b/docs/jaiph-skill.md
@@ -160,7 +160,7 @@ Conventions:
- Inside a workflow, `run` targets a workflow or script (local or `alias.name`), not a raw shell command. Call scripts with `run`, never `fn args` or `$(fn ...)`.
- Inside a rule, use `ensure` for **rules** and `run` for **scripts only** — not `prompt`, `send`, or `run async`.
- Treat rules as non-mutating checks; perform filesystem or agent mutations in **workflows**. Script steps from rules use the same managed subprocess path as workflows. Details: [Sandboxing](sandboxing.md).
-- **Parallelism:** `run async ref([args...])` for managed async with implicit join. For concurrent **bash**, use `&` and the shell builtin `wait` inside a **`script`** and call it with `run`. Do not call Jaiph internals from background subprocesses unless you understand `run.inbox_parallel` locking.
+- **Parallelism:** `run async ref([args...])` for managed async with implicit join. For concurrent **bash**, use `&` and the shell builtin `wait` inside a **`script`** and call it with `run`. Do not call Jaiph internals from background subprocesses unless you understand how isolation and logging interact with the runtime.
- **Shell conditions:** Express conditionals with `run` to a **script** and handle failure with `catch`, or use `if` / `match` for value branching. Short-circuit brace groups remain valid **inside `script`** bodies: `cmd || { ... }`.
- **No shell redirection around managed calls:** `run foo() > file`, `run foo() | cmd`, `run foo() &` are all `E_PARSE` errors — shell operators (`>`, `>>`, `|`, `&`) are not supported adjacent to `run` or `ensure` steps. Move shell pipelines and redirections into a **`script`** block and call it with `run`.
- **Script reuse:** Prefer `import script "./tool.py" as tool` (or a sibling `.jh` module) instead of maintaining ad-hoc bash outside the compiler. Avoid informal workspace-level shared-bash directories that bypass the module graph.
diff --git a/docs/libraries.md b/docs/libraries.md
index 31148f09..2f6d2bb8 100644
--- a/docs/libraries.md
+++ b/docs/libraries.md
@@ -78,9 +78,15 @@ Copies files from the **workspace** (or sandbox overlay) into the run’s `artif
import "jaiphlang/artifacts" as artifacts
workflow default() {
- # Copy a file into the artifacts directory under a chosen name.
- # Returns the absolute path of the saved artifact.
- const path = run artifacts.save("./build/output.bin", "build-output.bin")
+ # Single file:
+ const path = run artifacts.save("./build/output.bin")
+
+ # Or several files at once — newline-separated list of paths:
+ const paths = """
+ a.txt
+ b/nested.txt
+ """
+ const dests = run artifacts.save(paths)
}
```
@@ -88,4 +94,4 @@ workflow default() {
| Workflow | Description |
|----------|-------------|
-| `save(local_path, name)` | Requires `local_path` to be a **file**. Copies to `${JAIPH_ARTIFACTS_DIR}/${name}` (creates parent dirs). Returns the absolute destination path. |
+| `save(paths)` | `paths` is a single file path or a **newline-separated** list of file paths. Each file is copied to `${JAIPH_ARTIFACTS_DIR}/…` using the same relative path (`./` prefix stripped; absolute sources use `basename` only). Returns the absolute destination path(s), one per line, in order. Fails if the list is empty or any file is missing. |
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index 8a1b201e..1e56ebfb 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -160,7 +160,7 @@ If `/dev/fuse` is missing on the host, the CLI uses **copy mode**: before launch
**Workspace immutability contract** -- Docker runs cannot directly modify the host workspace. In overlay mode the host checkout is bind-mounted read-only and writes land in a tmpfs upper layer that is discarded on container exit. In copy mode the container writes to a separate host-side clone of the workspace (`/.sandbox-/`), which is removed on container exit unless explicitly kept for debugging. In both modes 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 by this contract.
-**Workspace patch export** -- To capture workspace changes as a patch, run `git diff` (or your own exporter) inside the workflow, write the result to a file under the workspace, then call `artifacts.save(local_path, name)` so the patch lands in the run’s `artifacts/` tree on the host. Callers choose when and what to record. The published GHCR runtime image includes `git` if you use it from a script step. See [Libraries — `jaiphlang/artifacts`](libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox).
+**Workspace patch export** -- To capture workspace changes as a patch, run `git diff` (or your own exporter) inside the workflow, write the result to a file under the workspace, then call `artifacts.save(local_path)` so the patch lands in the run’s `artifacts/` tree on the host. Callers choose when and what to record. The published GHCR runtime image includes `git` if you use it from a script step. See [Libraries — `jaiphlang/artifacts`](libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox).
**Network** -- `"default"` omits `--network`, which uses Docker's default bridge network (outbound access allowed). `"none"` passes `--network none` and fully disables networking -- use this for workflows that should not make external calls. Any other value (e.g. a custom Docker network name) is passed through as-is. Set `runtime.docker_network` in config or `JAIPH_DOCKER_NETWORK` in the environment.
diff --git a/docs/testing.md b/docs/testing.md
index 73b70921..3d7aa366 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -232,13 +232,13 @@ For each workflow run inside a test block, the harness builds the runtime enviro
| Variable | Value |
|---|---|
-| `JAIPH_TEST_MODE` | `1` |
+| `JAIPH_TEST_MODE` | `1` (selects mock prompt dispatch in `prompt.ts`) |
| `JAIPH_WORKSPACE` | Project root (from `detectWorkspaceRoot`) |
| `JAIPH_RUNS_DIR` | Per test block, `…/tmp/jaiph-test-block-*/.jaiph/runs` (ephemeral) |
| `JAIPH_SCRIPTS` | Directory containing extracted `script` files from `buildScripts` (temp) |
| `JAIPH_MOCK_RESPONSES_FILE` or `JAIPH_MOCK_DISPATCH_SCRIPT` | Set by the runner when using inline or block `mock prompt` (do not set manually) |
-You do not set `JAIPH_TEST_MODE` yourself; the harness manages it.
+You do not set `JAIPH_TEST_MODE` yourself; the harness manages it. Its only purpose is to route prompt steps to the mock dispatcher in `prompt.ts`. It no longer controls `__JAIPH_EVENT__` stderr suppression — the test runner now passes `suppressLiveEvents: true` directly to the in-process `NodeWorkflowRuntime` constructor so test reporter output stays clean. Durable `run_summary.jsonl` writes are unaffected; production runs (`jaiph run` via the spawned `node-workflow-runner` child) do not set the flag and stream events to stderr as before.
## Organizing tests
@@ -263,7 +263,7 @@ test "default workflow prints greeting" {
Compiler tests verify parse and validate outcomes using a language-agnostic txtar format. Unlike the TypeScript-embedded tests in `src/`, these fixtures are plain text files that can be reused by alternative implementations (e.g. a Rust compiler).
-Test fixture files live in `compiler-tests/` as `.txt` files. Each file contains multiple test cases separated by `===` delimiters:
+Test fixture files live in `test-fixtures/compiler-txtar/` as `.txt` files. Each file contains multiple test cases separated by `===` delimiters:
```
=== test name here
@@ -309,7 +309,7 @@ The entry file is determined by priority: `main.jh` if present, otherwise `input
npm run test:compiler
```
-The runner discovers all `.txt` files in `compiler-tests/`, parses them, writes virtual files to a temp directory per case, runs `parsejaiph` + `validateReferences`, and asserts the expected outcome. Results are reported per test case via `node:test`. Compiler tests are also included in `npm test`.
+The runner (`test-infra/compiler-test-runner.ts`) discovers all `.txt` files in `test-fixtures/compiler-txtar/`, parses them, writes virtual files to a temp directory per case, runs `parsejaiph` + `validateReferences`, and asserts the expected outcome. Results are reported per test case via `node:test`. Compiler tests are also included in `npm test`.
### Fixture files
@@ -317,10 +317,10 @@ Test cases are organized by error type and single-vs-multi-module:
| File | Cases | What it covers |
|------|-------|----------------|
-| `compiler-tests/valid.txt` | 119 | Success cases — source compiles without error (single-module) |
-| `compiler-tests/parse-errors.txt` | 274 | `E_PARSE` error cases — syntax and grammar violations |
-| `compiler-tests/validate-errors.txt` | 88 | `E_VALIDATE`, `E_IMPORT_NOT_FOUND`, `E_SCHEMA` error cases (single-module) |
-| `compiler-tests/validate-errors-multi-module.txt` | 20 | Validation errors requiring imports (multi-file) |
+| `test-fixtures/compiler-txtar/valid.txt` | 119 | Success cases — source compiles without error (single-module) |
+| `test-fixtures/compiler-txtar/parse-errors.txt` | 274 | `E_PARSE` error cases — syntax and grammar violations |
+| `test-fixtures/compiler-txtar/validate-errors.txt` | 88 | `E_VALIDATE`, `E_IMPORT_NOT_FOUND`, `E_SCHEMA` error cases (single-module) |
+| `test-fixtures/compiler-txtar/validate-errors-multi-module.txt` | 20 | Validation errors requiring imports (multi-file) |
(Counts are one `# @expect` per test case; re-count after large fixture changes.)
@@ -332,7 +332,7 @@ The initial cases were extracted from TypeScript test files across `src/parse/*.
- Test names should be descriptive and unique within a file.
- Keep test cases minimal — only include what is necessary to trigger the expected outcome.
-The format is documented in detail in `compiler-tests/README.md`.
+The format is documented in detail in `test-fixtures/compiler-txtar/README.md`.
## Golden AST tests
@@ -340,7 +340,7 @@ Golden AST tests verify that the parser produces the expected tree shape for suc
### How it works
-Each `.jh` fixture in `golden-ast/fixtures/` is parsed and serialized to deterministic JSON (locations and file paths stripped, keys sorted). The result is compared against a checked-in `.json` golden file in `golden-ast/expected/`.
+Each `.jh` fixture in `test-fixtures/golden-ast/fixtures/` is parsed and serialized to deterministic JSON (locations and file paths stripped, keys sorted). The result is compared against a checked-in `.json` golden file in `test-fixtures/golden-ast/expected/`.
- **Txtar tests** = error messages and "this compiles."
- **Golden AST tests** = parse tree shape for successful parses.
@@ -366,17 +366,17 @@ Review the diff to confirm the changes are expected, then commit the updated `.j
### Adding a new fixture
-1. Create a small, focused `.jh` file in `golden-ast/fixtures/` (one concern per file).
-2. Run `UPDATE_GOLDEN=1 npm run test:golden-ast` to generate `golden-ast/expected/.json`.
+1. Create a small, focused `.jh` file in `test-fixtures/golden-ast/fixtures/` (one concern per file).
+2. Run `UPDATE_GOLDEN=1 npm run test:golden-ast` to generate `test-fixtures/golden-ast/expected/.json`.
3. Review the generated JSON and commit both files.
## Stress and soak testing
-For concurrency-sensitive behavior (for example parallel inbox dispatch), the repository includes shell-based E2E scenarios that go beyond single native tests:
+For concurrency-sensitive behavior (for example inbox stress with many sends and route targets, or `run async` with interleaved managed steps), the repository includes shell-based E2E scenarios that go beyond single native tests:
-- High volume and fan-out to exercise locking and dispatch under concurrent writes.
+- High volume and fan-out to exercise locking and dispatch under concurrent writes to the same run directory.
- Soak loops to flush out intermittent failures.
-- Order-insensitive checks (counts, uniqueness) when parallel work makes ordering non-deterministic.
+- Order-insensitive checks (counts, uniqueness) when concurrent work makes ordering non-deterministic for the surface under test (for example async branch completion in the progress tree).
See `e2e/tests/91_inbox_dispatch.sh`, `e2e/tests/93_inbox_stress.sh`, and `e2e/tests/94_parallel_shell_steps.sh` for examples.
@@ -405,7 +405,7 @@ Similarly, every `.jh` and `.test.jh` file under `examples/` must be accounted f
## Landing-page sample verification
-The project includes a Playwright-based test (`tests/e2e-samples/landing-page.spec.ts`) that verifies landing-page code samples stay in sync with real CLI behavior. Run it with `npm run test:samples`. See [Contributing — Landing-page sample verification](contributing.md#landing-page-sample-verification-playwright) for details.
+The project includes a Playwright-based test (`e2e/playwright/landing-page.spec.ts`) that verifies landing-page code samples stay in sync with real CLI behavior. Run it with `npm run test:samples`. See [Contributing — Landing-page sample verification](contributing.md#landing-page-sample-verification-playwright) for details.
## Limitations (v1)
diff --git a/e2e/lib/common.sh b/e2e/lib/common.sh
index 2fadb35c..8b7dd080 100644
--- a/e2e/lib/common.sh
+++ b/e2e/lib/common.sh
@@ -418,17 +418,20 @@ e2e::prepare_shared_context() {
fi
mkdir -p "${JAIPH_E2E_BIN_DIR}" "${JAIPH_E2E_WORK_DIR}"
+ # Agent/nested jaiph sessions export many JAIPH_* variables (including *_LOCKED).
+ # E2E must start from a clean contract; `unset` individual keys is insufficient.
+ local _jaiph_var
+ while IFS= read -r _jaiph_var; do
+ case "${_jaiph_var}" in
+ JAIPH_E2E_* | JAIPH_REPO_URL | JAIPH_REPO_REF) continue ;;
+ esac
+ unset "${_jaiph_var}" 2>/dev/null || true
+ done < <(compgen -e | grep '^JAIPH_' || true)
+
export PATH="${JAIPH_E2E_BIN_DIR}:${PATH}"
export JAIPH_BIN_DIR="${JAIPH_E2E_BIN_DIR}"
# Docker sandbox is opt-in (beta); keep it disabled for e2e tests.
export JAIPH_DOCKER_ENABLED="${JAIPH_DOCKER_ENABLED:-false}"
- # Keep e2e deterministic by removing user/machine agent overrides.
- unset JAIPH_AGENT_MODEL
- unset JAIPH_AGENT_COMMAND
- unset JAIPH_AGENT_BACKEND
- unset JAIPH_AGENT_TRUSTED_WORKSPACE
- unset JAIPH_AGENT_CURSOR_FLAGS
- unset JAIPH_AGENT_CLAUDE_FLAGS
if [[ -z "${JAIPH_REPO_URL:-}" ]]; then
export JAIPH_REPO_URL="${E2E_REPO_ROOT}"
diff --git a/tests/e2e-samples/docs-site.ts b/e2e/playwright/docs-site.ts
similarity index 100%
rename from tests/e2e-samples/docs-site.ts
rename to e2e/playwright/docs-site.ts
diff --git a/tests/e2e-samples/landing-page.spec.ts b/e2e/playwright/landing-page.spec.ts
similarity index 100%
rename from tests/e2e-samples/landing-page.spec.ts
rename to e2e/playwright/landing-page.spec.ts
diff --git a/e2e/test_all.sh b/e2e/test_all.sh
index 657fb502..114c6a7a 100755
--- a/e2e/test_all.sh
+++ b/e2e/test_all.sh
@@ -81,8 +81,13 @@ TEST_SCRIPTS=(
"e2e/tests/126_file_shorthand_routing.sh"
"e2e/tests/127_cli_edge_cases.sh"
"e2e/tests/128_examples_format_check.sh"
+ "e2e/tests/128_if_statement.sh"
+ "e2e/tests/129_artifacts_lib.sh"
"e2e/tests/130_run_recover_loop.sh"
+ "e2e/tests/131_tty_async_progress.sh"
+ "e2e/tests/132_return_log_inline_script.sh"
"e2e/tests/133_return_bare_identifier.sh"
+ "e2e/tests/134_script_imports.sh"
)
PASS_COUNT=0
diff --git a/e2e/tests/112_interpreter_tags.sh b/e2e/tests/112_interpreter_tags.sh
index 1c64c1bf..913bee34 100755
--- a/e2e/tests/112_interpreter_tags.sh
+++ b/e2e/tests/112_interpreter_tags.sh
@@ -108,7 +108,7 @@ if run_out="$(e2e::run "bad_tag.jh" 2>&1)"; then
e2e::fail "expected compile error for unknown tag, but run succeeded"
else
# nondeterministic: error includes absolute file path prefix which varies
- e2e::assert_contains "${run_out}" 'script:lang syntax is no longer supported' "unknown tag produces actionable error"
+ e2e::assert_contains "${run_out}" 'unsupported top-level statement: script:golang' "unknown tag produces parse error"
fi
# ---------- script:node with manual shebang: compile error ----------
diff --git a/e2e/tests/129_artifacts_lib.sh b/e2e/tests/129_artifacts_lib.sh
index 2169a2b9..5eb39bcf 100755
--- a/e2e/tests/129_artifacts_lib.sh
+++ b/e2e/tests/129_artifacts_lib.sh
@@ -22,7 +22,7 @@ e2e::file "artifacts_e2e.jh" <<'EOF'
import "jaiphlang/artifacts" as artifacts
workflow default() {
- const save_path = run artifacts.save("./build_output.txt", "saved-output.txt")
+ const save_path = run artifacts.save("./build_output.txt")
log save_path
}
EOF
@@ -36,8 +36,8 @@ e2e::assert_contains "${artifacts_out}" "PASS" "output contains PASS"
run_dir="$(e2e::run_dir "artifacts_e2e.jh")"
artifacts_dir="${run_dir}artifacts"
-e2e::assert_file_exists "${artifacts_dir}/saved-output.txt" "saved artifact exists"
-saved_content="$(<"${artifacts_dir}/saved-output.txt")"
+e2e::assert_file_exists "${artifacts_dir}/build_output.txt" "saved artifact exists"
+saved_content="$(<"${artifacts_dir}/build_output.txt")"
e2e::assert_equals "${saved_content}" "build-output-content" "saved artifact content matches source"
e2e::pass "artifacts save"
diff --git a/e2e/tests/134_script_imports.sh b/e2e/tests/134_script_imports.sh
new file mode 100755
index 00000000..a97d2f79
--- /dev/null
+++ b/e2e/tests/134_script_imports.sh
@@ -0,0 +1,149 @@
+#!/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
+
+HAS_PYTHON3=0
+command -v python3 >/dev/null 2>&1 && HAS_PYTHON3=1
+
+# ---------------------------------------------------------------------------
+e2e::section "import script: shell script via run"
+# ---------------------------------------------------------------------------
+
+e2e::prepare_test_env "script_import_shell"
+
+e2e::file "greet.sh" <<'EOF'
+#!/usr/bin/env bash
+echo "hello from imported shell"
+EOF
+chmod +x "${JAIPH_E2E_TEST_DIR}/greet.sh"
+
+e2e::file "main_shell.jh" <<'EOF'
+import script "./greet.sh" as greet
+
+workflow default() {
+ run greet()
+}
+EOF
+
+shell_out="$(e2e::run "main_shell.jh")"
+
+e2e::expect_stdout "${shell_out}" <<'EOF'
+
+Jaiph: Running main_shell.jh
+
+workflow default
+ ▸ script greet
+ ✓ script greet ()
+✓ PASS workflow default ()
+EOF
+
+e2e::expect_out "main_shell.jh" "greet" "hello from imported shell"
+
+e2e::pass "import script: shell script via run"
+
+# ---------------------------------------------------------------------------
+e2e::section "import script: capture stdout into const"
+# ---------------------------------------------------------------------------
+
+e2e::prepare_test_env "script_import_capture"
+
+e2e::file "emit.sh" <<'EOF'
+#!/usr/bin/env bash
+echo "captured-from-shell"
+EOF
+chmod +x "${JAIPH_E2E_TEST_DIR}/emit.sh"
+
+e2e::file "main_capture.jh" <<'EOF'
+import script "./emit.sh" as emit
+
+script consume = `echo "consumed: $1"`
+
+workflow default() {
+ const val = run emit()
+ run consume(val)
+}
+EOF
+
+cap_out="$(e2e::run "main_capture.jh")"
+
+e2e::expect_stdout "${cap_out}" <<'EOF'
+
+Jaiph: Running main_capture.jh
+
+workflow default
+ ▸ script emit
+ ✓ script emit ()
+ ▸ script consume (1="captured-from-shell")
+ ✓ script consume ()
+
+✓ PASS workflow default ()
+EOF
+
+e2e::expect_out "main_capture.jh" "consume" "consumed: captured-from-shell"
+
+e2e::pass "import script: capture stdout into const"
+
+# ---------------------------------------------------------------------------
+e2e::section "import script: missing file fails at compile time"
+# ---------------------------------------------------------------------------
+
+e2e::prepare_test_env "script_import_missing"
+
+e2e::file "main_missing.jh" <<'EOF'
+import script "./does_not_exist.py" as ghost
+
+workflow default() {
+ run ghost()
+}
+EOF
+
+if run_out="$(e2e::run "main_missing.jh" 2>&1)"; then
+ e2e::fail "expected compile error for missing script import, but run succeeded"
+else
+ # nondeterministic: error includes absolute file path prefix which varies
+ e2e::assert_contains "${run_out}" 'resolves to missing file' "missing script import produces E_IMPORT_NOT_FOUND"
+fi
+
+# ---------------------------------------------------------------------------
+e2e::section "import script: python script with shebang"
+# ---------------------------------------------------------------------------
+
+if [[ "${HAS_PYTHON3}" -eq 0 ]]; then
+ e2e::skip "python3 not in PATH — skipping python import test"
+else
+ e2e::prepare_test_env "script_import_python"
+
+ e2e::file "queue.py" <<'EOF'
+#!/usr/bin/env python3
+import sys
+sys.stdout.write("hello-from-imported-python\n")
+EOF
+
+ e2e::file "main_python.jh" <<'EOF'
+import script "./queue.py" as queue
+
+workflow default() {
+ run queue()
+}
+EOF
+
+ py_out="$(e2e::run "main_python.jh")"
+
+ e2e::expect_stdout "${py_out}" <<'EOF'
+
+Jaiph: Running main_python.jh
+
+workflow default
+ ▸ script queue
+ ✓ script queue ()
+✓ PASS workflow default ()
+EOF
+
+ e2e::expect_out "main_python.jh" "queue" "hello-from-imported-python"
+
+ e2e::pass "import script: python script with shebang"
+fi
diff --git a/e2e/tests/70_run_artifacts.sh b/e2e/tests/70_run_artifacts.sh
index 68cf9950..502ccf27 100644
--- a/e2e/tests/70_run_artifacts.sh
+++ b/e2e/tests/70_run_artifacts.sh
@@ -77,12 +77,10 @@ workflow default() {
}
EOF
rm -rf "${TEST_DIR}/runs_prompt_script"
-mock_resp="${TEST_DIR}/mock_prompt_once.txt"
-printf '%s\n' "mock-final-line" >"${mock_resp}"
JAIPH_RUNS_DIR="${TEST_DIR}/runs_prompt_script" \
JAIPH_TEST_MODE=1 \
- JAIPH_MOCK_RESPONSES_FILE="${mock_resp}" \
+ JAIPH_MOCK_RESPONSES_JSON='["mock-final-line"]' \
jaiph run "${TEST_DIR}/prompt_then_script.jh" >/dev/null
run_dir_ps="$(e2e::run_dir_at "${TEST_DIR}/runs_prompt_script" "prompt_then_script.jh")"
diff --git a/e2e/tests/78_lang_redesign_constructs.sh b/e2e/tests/78_lang_redesign_constructs.sh
index 04204682..4619a92d 100644
--- a/e2e/tests/78_lang_redesign_constructs.sh
+++ b/e2e/tests/78_lang_redesign_constructs.sh
@@ -263,8 +263,9 @@ code=$?
set -e
[[ ${code} -ne 0 ]] || e2e::fail "structured rule should have failed"
-# assert_contains: FAIL output includes absolute run-dir paths which vary per invocation
-e2e::assert_contains "${out}" "Workflow execution failed." "structured rule failure is reported"
+# Detailed failure excerpts suppress the generic summary line (resolveFailureDetails).
+e2e::assert_contains "${out}" "FAIL workflow default" "structured rule failure footer"
+e2e::assert_contains "${out}" "name is required" "fail() output surfaces under failed step"
# ---------------------------------------------------------------------------
e2e::section "run targeting workflow inside rule is rejected"
diff --git a/e2e/tests/88_run_summary_event_contract.sh b/e2e/tests/88_run_summary_event_contract.sh
index ea948aef..414b45ec 100644
--- a/e2e/tests/88_run_summary_event_contract.sh
+++ b/e2e/tests/88_run_summary_event_contract.sh
@@ -32,13 +32,9 @@ trap e2e::cleanup EXIT
e2e::prepare_test_env "run_summary_event_contract"
TEST_DIR="${JAIPH_E2E_TEST_DIR}"
-e2e::section "run_summary.jsonl contract under parallel inbox dispatch"
+e2e::section "run_summary.jsonl contract under multi-target inbox dispatch"
e2e::file "summary_contract.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ch -> receiver_a, receiver_b
script emit_payload = `echo "contract-payload"`
@@ -216,4 +212,4 @@ for wt in want_types:
sys.exit(f"missing event type {wt!r} in run_summary.jsonl")
PY
-e2e::pass "run_summary.jsonl: LOG persistence, enqueue event, dispatch pairing, step pairing, workflow balance, JSONL validity (parallel)"
+e2e::pass "run_summary.jsonl: LOG persistence, enqueue event, dispatch pairing, step pairing, workflow balance, JSONL validity (multi-target inbox)"
diff --git a/e2e/tests/91_inbox_dispatch.sh b/e2e/tests/91_inbox_dispatch.sh
index 42cf37b9..2967d1ce 100755
--- a/e2e/tests/91_inbox_dispatch.sh
+++ b/e2e/tests/91_inbox_dispatch.sh
@@ -234,14 +234,10 @@ e2e::assert_file_exists "${TEST_DIR}/args.txt" "receiver wrote args file"
expected_args="$(printf 'msg=payload-data\nchannel=events\nsender=producer')"
e2e::assert_equals "$(cat "${TEST_DIR}/args.txt")" "${expected_args}" "receiver positional args (\$1=message, \$2=channel, \$3=sender)"
-e2e::section "Parallel dispatch: multi-target route executes all targets"
+e2e::section "Multi-target route: all targets execute"
# Given
e2e::file "parallel_multi.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel results -> consumer_a, consumer_b
script emit_parallel_payload = `echo "parallel-payload"`
@@ -268,19 +264,15 @@ EOF
e2e::run "parallel_multi.jh" >/dev/null
# Then
-e2e::assert_file_exists "${TEST_DIR}/consumer_a_par.txt" "parallel: consumer_a was dispatched"
-e2e::assert_equals "$(cat "${TEST_DIR}/consumer_a_par.txt")" "A got: parallel-payload" "parallel: consumer_a receives message"
-e2e::assert_file_exists "${TEST_DIR}/consumer_b_par.txt" "parallel: consumer_b was dispatched"
-e2e::assert_equals "$(cat "${TEST_DIR}/consumer_b_par.txt")" "B got: parallel-payload" "parallel: consumer_b receives message"
+e2e::assert_file_exists "${TEST_DIR}/consumer_a_par.txt" "multi-target: consumer_a was dispatched"
+e2e::assert_equals "$(cat "${TEST_DIR}/consumer_a_par.txt")" "A got: parallel-payload" "multi-target: consumer_a receives message"
+e2e::assert_file_exists "${TEST_DIR}/consumer_b_par.txt" "multi-target: consumer_b was dispatched"
+e2e::assert_equals "$(cat "${TEST_DIR}/consumer_b_par.txt")" "B got: parallel-payload" "multi-target: consumer_b receives message"
-e2e::section "Parallel dispatch: no duplicate/skipped sequence IDs under concurrent sends"
+e2e::section "Multi-target route: no duplicate/skipped sequence IDs under multiple sends"
-# Given — two workflows each send to the same inbox; parallel dispatch exercises lock paths
+# Given — two workflows each send to the same inbox
e2e::file "parallel_seq.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel data -> sink
script emit_from_a = `echo "from-a"`
@@ -321,16 +313,12 @@ sink_lines="$(wc -l < "${TEST_DIR}/sink_log.txt" | tr -d ' ')"
if [[ "$sink_lines" -ne 2 ]]; then
e2e::fail "expected 2 sink invocations, got ${sink_lines}"
fi
-e2e::pass "parallel: no duplicate/skipped sequences — exactly 2 dispatches"
+e2e::pass "multi-send: no duplicate/skipped sequences — exactly 2 dispatches"
-e2e::section "Parallel dispatch: failed target causes workflow failure"
+e2e::section "Multi-target route: failed target causes workflow failure"
# Given
e2e::file "parallel_fail.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ch -> good_target, bad_target
script emit_msg = `echo "msg"`
@@ -359,18 +347,14 @@ e2e::run "parallel_fail.jh" >/dev/null 2>/dev/null || par_fail_exit=$?
# Then
if [[ "$par_fail_exit" -eq 0 ]]; then
- e2e::fail "parallel: expected non-zero exit when a target fails"
+ e2e::fail "multi-target: expected non-zero exit when a target fails"
fi
-e2e::pass "parallel: failed target propagates failure to owning workflow"
+e2e::pass "multi-target: failed target propagates failure to owning workflow"
-e2e::section "Parallel dispatch: run summary valid under concurrent activity"
+e2e::section "Multi-target route: run summary stays valid JSON"
# Given
e2e::file "parallel_summary.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel events -> handler_a, handler_b
script emit_e1 = `echo "e1"`
@@ -396,7 +380,7 @@ EOF
# When
e2e::run "parallel_summary.jh" >/dev/null
-# Then — each STEP_END line must be valid JSON (no corruption from concurrent appends)
+# Then — each line must be valid JSON
run_dir="$(e2e::run_dir "parallel_summary.jh")"
summary="${run_dir}/run_summary.jsonl"
e2e::assert_file_exists "$summary" "run_summary.jsonl exists"
@@ -409,38 +393,4 @@ done < "$summary"
if [[ "$invalid_lines" -gt 0 ]]; then
e2e::fail "run_summary.jsonl has ${invalid_lines} invalid JSON lines"
fi
-e2e::pass "parallel: run_summary.jsonl is valid under concurrent writes"
-
-e2e::section "Parallel dispatch via JAIPH_INBOX_PARALLEL env var"
-
-# Given — same workflow as basic multi-target, but parallel enabled via env
-e2e::file "env_parallel.jh" <<'EOF'
-channel results -> consumer_a, consumer_b
-
-script emit_env_parallel = `echo "env-parallel"`
-workflow producer() {
- results <- run emit_env_parallel()
-}
-
-script write_env_a = `echo "A: $1" > env_a.txt`
-workflow consumer_a(message, chan, sender) {
- run write_env_a(message)
-}
-
-script write_env_b = `echo "B: $1" > env_b.txt`
-workflow consumer_b(message, chan, sender) {
- run write_env_b(message)
-}
-
-workflow default() {
- run producer()
-}
-EOF
-
-# When
-JAIPH_INBOX_PARALLEL=true e2e::run "env_parallel.jh" >/dev/null
-
-# Then
-e2e::assert_file_exists "${TEST_DIR}/env_a.txt" "env parallel: consumer_a dispatched"
-e2e::assert_file_exists "${TEST_DIR}/env_b.txt" "env parallel: consumer_b dispatched"
-e2e::pass "parallel mode activatable via JAIPH_INBOX_PARALLEL env var"
+e2e::pass "multi-target: run_summary.jsonl lines are valid JSON"
diff --git a/e2e/tests/93_inbox_stress.sh b/e2e/tests/93_inbox_stress.sh
index 0ad81e52..aaff5d6a 100755
--- a/e2e/tests/93_inbox_stress.sh
+++ b/e2e/tests/93_inbox_stress.sh
@@ -69,14 +69,9 @@ e2e::section "High-volume send: 10 senders, sequence IDs gapless and unique"
# ===========================================================================
# 10 workflows each send one message to the same channel.
-# Under parallel dispatch, all 10 sends race through the lock.
# We assert: exactly 10 inbox files (001..010), no gaps, no duplicates.
e2e::file "stress_highvol.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel data -> sink
script emit_m1 = `echo "m1"`
@@ -173,10 +168,6 @@ e2e::section "Fan-out correctness: 3 messages x 3 targets = 9 invocations"
# ===========================================================================
e2e::file "stress_fanout.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ch -> target_x, target_y, target_z
script emit_pa = `echo "pa"`
@@ -257,10 +248,6 @@ e2e::section "Nested dispatch: dispatched workflow sends further messages"
# Verifies reentrancy: a dispatched workflow can itself send to inbox.
e2e::file "stress_nested.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ch_raw -> processor
channel ch_processed -> sink
@@ -301,15 +288,11 @@ e2e::assert_file_exists "${nd_inbox}/002-ch_processed.txt" "nested: ch_processed
e2e::section "Failure aggregation: multiple failing targets"
# ===========================================================================
-# Two targets fail, one succeeds. Verify workflow fails and the successful
-# target still ran (all targets in a parallel batch complete before exit).
+# Two targets fail, one succeeds. Verify workflow fails and a successful target
+# listed before the failing targets still ran (sequential fail-fast after good work).
e2e::file "stress_failagg.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
-channel ch -> fail_a, fail_b, good
+channel ch -> good, fail_a, fail_b
script emit_msg = `echo "msg"`
@@ -354,20 +337,17 @@ if [[ "$fail_exit" -eq 0 ]]; then
fi
e2e::pass "failure aggregation: workflow exited non-zero"
-# The good target should still have run (parallel waits for all)
+# The good target should still have run (listed first; fail-fast stops at fail_a)
+
e2e::assert_file_exists "${TEST_DIR}/fail_good_ran.txt" "failure aggregation: good target completed"
# ===========================================================================
e2e::section "Concurrent artifact integrity: inbox + summary under load"
# ===========================================================================
-# 5 senders x 2 targets in parallel — check inbox files, queue, and summary.
+# 5 senders x 2 targets — check inbox files, queue, and summary.
e2e::file "stress_artifacts.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ev -> t1, t2
script emit_e1 = `echo "e1"`
@@ -439,17 +419,13 @@ assert_valid_jsonl "${art_run_dir}/run_summary.jsonl" "artifacts: run_summary.js
e2e::section "Soak run: 5 iterations of fan-out scenario prove stability"
# ===========================================================================
-# Repeat the same parallel fan-out scenario multiple times.
+# Repeat the same fan-out scenario multiple times.
# Each iteration uses a fresh build+run via e2e::run.
# Detects heisenbugs that only manifest under repeated execution.
SOAK_ITERATIONS=5
e2e::file "stress_soak.jh" <<'EOF'
-config {
- run.inbox_parallel = true
-}
-
channel ch -> t1, t2
script soak_emit_i1 = `echo "i1"`
@@ -527,11 +503,11 @@ done
e2e::pass "soak: all ${SOAK_ITERATIONS} iterations passed — dispatch counts, JSONL validity, sequence integrity"
# ===========================================================================
-e2e::section "Sequential mode: same high-volume scenario produces identical results"
+e2e::section "High-volume replay: second 10-sender workflow matches inbox invariants"
# ===========================================================================
-# Run the 10-sender scenario in sequential mode and verify same invariants.
-# This confirms sequential path is not regressed by parallel-mode changes.
+# Same shape as stress_highvol with distinct script names.
+# Confirms a second high-volume scenario still yields gapless sequences.
e2e::file "stress_seq_mode.jh" <<'EOF'
channel data -> sink
diff --git a/examples/async.jh b/examples/async.jh
index c7348434..8abcd209 100755
--- a/examples/async.jh
+++ b/examples/async.jh
@@ -3,13 +3,17 @@
const prompt_text = "Say: Greetings! I am [model name]."
workflow cursor_say_hello(name) {
- config { agent.backend = "cursor" }
+ config {
+ agent.backend = "cursor"
+ }
const response = prompt "${prompt_text}"
log response
}
workflow claude_say_hello(name) {
- config { agent.backend = "claude" }
+ config {
+ agent.backend = "claude"
+ }
const response = prompt "${prompt_text}"
log response
}
diff --git a/test/run-summary-jsonl.test.ts b/integration/run-summary-jsonl.test.ts
similarity index 95%
rename from test/run-summary-jsonl.test.ts
rename to integration/run-summary-jsonl.test.ts
index 9d2ff173..e4aa1a66 100644
--- a/test/run-summary-jsonl.test.ts
+++ b/integration/run-summary-jsonl.test.ts
@@ -74,18 +74,18 @@ test("run_summary.jsonl: workflow, steps, log, inbox dispatch stream", () => {
].join("\n"),
);
+ const runsRoot = join(root, ".jaiph/runs");
const runResult = spawnSync("node", [cliPath, "run", jh], {
encoding: "utf8",
cwd: root,
env: {
...process.env,
JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
},
});
assert.equal(runResult.status, 0, runResult.stderr);
-
- const runsRoot = join(root, ".jaiph/runs");
const runDir = latestRunDir(runsRoot);
assert.ok(runDir, "run dir");
const summaryPath = join(runDir, "run_summary.jsonl");
@@ -156,13 +156,18 @@ test("run_summary.jsonl: STEP_END remains parseable for legacy consumers (event_
jh,
['script emit_x = `echo "x"`', "workflow default() {", " run emit_x()", "}", ""].join("\n"),
);
+ const runsRoot = join(root, ".jaiph/runs");
const runResult = spawnSync("node", [cliPath, "run", jh], {
encoding: "utf8",
cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ env: {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: runsRoot,
+ },
});
assert.equal(runResult.status, 0, runResult.stderr);
- const runDir = latestRunDir(join(root, ".jaiph/runs"));
+ const runDir = latestRunDir(runsRoot);
const lines = readFileSync(join(runDir, "run_summary.jsonl"), "utf8")
.trim()
.split("\n");
diff --git a/integration/sample-build/build.test.ts b/integration/sample-build/build.test.ts
new file mode 100644
index 00000000..6964f256
--- /dev/null
+++ b/integration/sample-build/build.test.ts
@@ -0,0 +1,393 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildScripts, resolveImportPath } from "../../src/transpiler";
+import { parsejaiph } from "../../src/parser";
+
+import "./helpers";
+
+test("buildScripts extracts scripts for fixture corpus", () => {
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-build-"));
+ try {
+ buildScripts(join(process.cwd(), "test-fixtures/sample-build/fixtures"), outDir);
+ const scriptsDir = join(outDir, "scripts");
+ assert.ok(existsSync(scriptsDir));
+ assert.ok(readdirSync(scriptsDir).length > 0);
+ } finally {
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("build validates imported rule references with deterministic errors", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-invalid-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ 'import "./mod.jh" as mod',
+ "",
+ "script local_impl = `echo ok`",
+ "rule local() {",
+ " run local_impl()",
+ "}",
+ "",
+ "workflow main() {",
+ " ensure mod.missing()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "mod.jh"),
+ [
+ "script existing_impl = `echo hi`",
+ "rule existing() {",
+ " run existing_impl()",
+ "}",
+ "",
+ "workflow mod() {",
+ " ensure existing()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ assert.throws(() => buildScripts(root, join(root, "out")), /E_VALIDATE imported rule "mod\.missing" does not exist/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("build fails on missing import file", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-import-missing-"));
+ try {
+ mkdirSync(join(root, "sub"));
+ writeFileSync(
+ join(root, "sub/entry.jh"),
+ [
+ 'import "../missing/mod.jh" as mod',
+ "",
+ "rule local() {",
+ " echo ok",
+ "}",
+ "",
+ "workflow entry() {",
+ " ensure local()",
+ " ensure mod.anything()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ assert.throws(() => buildScripts(root, join(root, "out")), /E_IMPORT_NOT_FOUND import "mod" resolves to missing file/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// Regression: .jaiph/main.jh once imported implement_from_queue.jh which had been
+// renamed to engineer.jh, causing E_IMPORT_NOT_FOUND for every `jaiph test` run
+// in the workspace. `jaiph test` now builds from the test file entrypoint only;
+// this still checks main.jh imports and that the whole `.jaiph` graph builds.
+test(".jaiph/main.jh imports only existing modules", () => {
+ const jaiphDir = join(process.cwd(), ".jaiph");
+ const mainJh = join(jaiphDir, "main.jh");
+ assert.ok(existsSync(mainJh), ".jaiph/main.jh should exist");
+
+ const ast = parsejaiph(readFileSync(mainJh, "utf8"), mainJh);
+ for (const imp of ast.imports) {
+ const resolved = resolveImportPath(mainJh, imp.path, process.cwd());
+ assert.ok(existsSync(resolved), `import "${imp.alias}" resolves to missing file "${resolved}"`);
+ }
+
+ const outDir = join(jaiphDir, ".tmp-build-out");
+ try {
+ assert.doesNotThrow(() => buildScripts(jaiphDir, outDir, process.cwd()));
+ } finally {
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("build rejects command substitution in prompt text", () => {
+ const rootSubshell = mkdtempSync(join(tmpdir(), "jaiph-build-prompt-subshell-"));
+ try {
+ writeFileSync(
+ join(rootSubshell, "main.jh"),
+ [
+ "workflow default() {",
+ ' prompt "literal command substitution: $(echo SHOULD_NOT_RUN)"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ assert.throws(
+ () => buildScripts(rootSubshell, join(rootSubshell, "out")),
+ /E_PARSE prompt cannot contain command substitution/,
+ );
+ } finally {
+ rmSync(rootSubshell, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts accepts files with no workflows", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-no-workflows-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-no-workflows-out-"));
+ try {
+ const filePath = join(root, "rules-only.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script only_rule_impl = `echo ok`",
+ "rule only_rule() {",
+ " run only_rule_impl()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ assert.ok(existsSync(join(outDir, "scripts", "only_rule_impl")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts extracts scripts for ensure-with-args workflow", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-args-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-args-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script check_branch_impl = \`\`\`",
+ "test \"$1\" = \"main\"",
+ "\`\`\`",
+ "rule check_branch(branch) {",
+ " run check_branch_impl(branch)",
+ "}",
+ "",
+ "workflow default(name) {",
+ " ensure check_branch(name)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ assert.ok(readFileSync(join(outDir, "scripts", "check_branch_impl"), "utf8").includes("test "));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts writes multiple script stubs", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-functions-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-functions-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script changed_files = `printf '%s' 'from-function'`",
+ "script print_value = \`\`\`",
+ "printf '%s\\n' \"$1\"",
+ "\`\`\`",
+ "",
+ "workflow default() {",
+ " const VALUE = run changed_files()",
+ ' run print_value(VALUE)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ const names = readdirSync(join(outDir, "scripts")).sort();
+ assert.deepEqual(names, ["changed_files", "print_value"]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("build fails when run in rule references unknown symbol", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-in-rule-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-run-in-rule-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "rule bad() {",
+ " run some_workflow()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure bad()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ assert.throws(
+ () => buildScripts(filePath, outDir),
+ /unknown local script reference.*run in rules must target a script/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("build fails when run in rule targets a workflow", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-wf-in-rule-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-run-wf-in-rule-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow helper() {",
+ ' log "hi"',
+ "}",
+ "",
+ "rule bad() {",
+ " run helper()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure bad()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ assert.throws(
+ () => buildScripts(filePath, outDir),
+ /run inside a rule must target a script, not workflow/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts accepts ensure inside a rule block", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-in-rule-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-in-rule-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script dep_impl = `echo dep`",
+ "rule dep() {",
+ " run dep_impl()",
+ "}",
+ "",
+ "rule main() {",
+ " ensure dep()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure main()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ assert.ok(existsSync(join(outDir, "scripts", "dep_impl")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts extracts scripts for ensure ... catch workflow", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script dep_impl = `test -f ready.txt`",
+ "rule dep() {",
+ " run dep_impl()",
+ "}",
+ "",
+ "script install_deps_impl = `touch ready.txt`",
+ "",
+ "workflow install_deps() {",
+ " run install_deps_impl()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure dep() catch (failure) run install_deps()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ assert.ok(readdirSync(join(outDir, "scripts")).includes("install_deps_impl"));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("build accepts ensure catch body with raw shell lines", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-block-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-block-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script ready_impl = `test -f ready.txt`",
+ "rule ready() {",
+ " run ready_impl()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure ready() catch (failure) { echo fixing; touch ready.txt; }",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ buildScripts(filePath, outDir);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("buildScripts accepts multiline raw shell in workflow (assignment-style lines)", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-assign-fail-"));
+ const outDir = mkdtempSync(join(tmpdir(), "jaiph-assign-fail-out-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow default() {",
+ " out = false",
+ " echo done",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ buildScripts(filePath, outDir);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/cli-tree.test.ts b/integration/sample-build/cli-tree.test.ts
new file mode 100644
index 00000000..bafc96df
--- /dev/null
+++ b/integration/sample-build/cli-tree.test.ts
@@ -0,0 +1,335 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { spawnSync } from "node:child_process";
+import { buildRunTreeRows } from "../../src/cli";
+import { formatRunningBottomLine } from "../../src/cli/run/progress";
+import { parseStepEvent } from "../../src/cli/run/events";
+import { parsejaiph } from "../../src/parser";
+
+import "./helpers";
+
+test("jaiph init creates workspace structure and guidance", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-init-"));
+ try {
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const skillPath = join(process.cwd(), "docs/jaiph-skill.md");
+ const initResult = spawnSync("node", [cliPath, "init"], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, ...(existsSync(skillPath) ? { JAIPH_SKILL_PATH: skillPath } : {}) },
+ });
+
+ assert.equal(initResult.status, 0, initResult.stderr);
+ assert.equal(existsSync(join(root, ".jaiph")), true);
+ assert.equal(existsSync(join(root, ".jaiph/lib")), false);
+ assert.equal(existsSync(join(root, ".jaiph/bootstrap.jh")), true);
+ assert.equal(existsSync(join(root, ".jaiph/SKILL.md")), true);
+ const bootstrap = readFileSync(join(root, ".jaiph/bootstrap.jh"), "utf8");
+ assert.match(bootstrap, /^#!\/usr\/bin\/env jaiph\n\n/);
+ assert.match(bootstrap, /workflow default\(\) \{/);
+ assert.match(bootstrap, /\.jaiph\/SKILL\.md/);
+ assert.match(bootstrap, /Analyze repository structure/);
+ assert.match(bootstrap, /Create or update Jaiph workflows under \.jaiph\//);
+ assert.doesNotMatch(bootstrap, /\$1/);
+ assert.equal(statSync(join(root, ".jaiph/bootstrap.jh")).mode & 0o777, 0o755);
+ const localSkill = readFileSync(join(root, ".jaiph/SKILL.md"), "utf8");
+ assert.match(localSkill, /Jaiph Bootstrap Skill/);
+ assert.equal(existsSync(join(root, ".gitignore")), false);
+ assert.equal(readFileSync(join(root, ".jaiph", ".gitignore"), "utf8"), "runs\ntmp\n");
+ assert.match(initResult.stdout, /Jaiph init/);
+ assert.match(initResult.stdout, /▸ Creating \.jaiph\/bootstrap\.jh/);
+ assert.match(initResult.stdout, /✓ Initialized \.jaiph\/bootstrap\.jh/);
+ assert.match(initResult.stdout, /✓ Created \.jaiph\/\.gitignore/);
+ assert.match(initResult.stdout, /Wrote \.jaiph\/SKILL\.md from installation/);
+ assert.match(initResult.stdout, /\.\/\.jaiph\/bootstrap\.jh/);
+ assert.match(initResult.stdout, /analyze the project/i);
+ assert.match(initResult.stdout, /\.jaiph\/\.gitignore/i);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph use maps nightly and version refs for reinstallation", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-use-"));
+ try {
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const installSpy = join(root, "install-spy.sh");
+ const outputPath = join(root, "used-ref.txt");
+ writeFileSync(
+ installSpy,
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ "printf '%s' \"$JAIPH_REPO_REF\" > \"$JAIPH_USE_REF_OUT\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(installSpy, 0o755);
+
+ const nightlyResult = spawnSync("node", [cliPath, "use", "nightly"], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_INSTALL_COMMAND: `"${installSpy}"`,
+ JAIPH_USE_REF_OUT: outputPath,
+ },
+ });
+ assert.equal(nightlyResult.status, 0, nightlyResult.stderr);
+ assert.equal(readFileSync(outputPath, "utf8"), "nightly");
+
+ const versionResult = spawnSync("node", [cliPath, "use", "0.2.3"], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_INSTALL_COMMAND: `"${installSpy}"`,
+ JAIPH_USE_REF_OUT: outputPath,
+ },
+ });
+ assert.equal(versionResult.status, 0, versionResult.stderr);
+ assert.equal(readFileSync(outputPath, "utf8"), "v0.2.3");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run tree includes function calls from workflow shell steps", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-function-tree-"));
+ try {
+ const filePath = join(root, "entry.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script changed_files = `printf '%s' 'from-function'`",
+ "script print_value = \`\`\`",
+ "printf '%s\\n' \"$1\"",
+ "\`\`\`",
+ "",
+ "workflow default() {",
+ " const VALUE = run changed_files()",
+ ' run print_value(VALUE)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /workflow default/);
+ assert.match(runResult.stdout, /script changed_files/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("parseStepEvent parses params array from event payload", () => {
+ const line =
+ '__JAIPH_EVENT__ {"type":"STEP_START","func":"main::docs_page","kind":"workflow","name":"docs_page","ts":"2025-01-01T00:00:00Z","status":null,"elapsed_ms":null,"out_file":"","err_file":"","id":"run:1:1","parent_id":"run:0:0","seq":1,"depth":1,"run_id":"run-1","params":[["path","docs/cli.md"],["mode","strict"]]}';
+ const event = parseStepEvent(line);
+ assert.ok(event);
+ assert.equal(event?.kind, "workflow");
+ assert.equal(event?.name, "docs_page");
+ assert.equal(event?.params?.length, 2);
+ assert.deepEqual(event?.params?.[0], ["path", "docs/cli.md"]);
+ assert.deepEqual(event?.params?.[1], ["mode", "strict"]);
+});
+
+test("parseStepEvent returns empty params when payload has no params", () => {
+ const line =
+ '__JAIPH_EVENT__ {"type":"STEP_START","func":"main::default","kind":"workflow","name":"default","ts":"2025-01-01T00:00:00Z","status":null,"elapsed_ms":null,"out_file":"","err_file":"","id":"run:1:1","parent_id":null,"seq":1,"depth":0,"run_id":"run-1"}';
+ const event = parseStepEvent(line);
+ assert.ok(event);
+ assert.equal(event?.params?.length, 0);
+});
+
+test("formatRunningBottomLine produces TTY bottom line with RUNNING, workflow name, and elapsed time", () => {
+ const line = formatRunningBottomLine("default", 2.6);
+ assert.ok(line.includes("RUNNING"), "contains RUNNING");
+ assert.ok(line.includes("workflow"), "contains workflow");
+ assert.ok(line.includes("default"), "contains workflow name");
+ assert.match(line, /\(\d+\.\ds\)/, "contains (X.Xs) time");
+});
+
+test("jaiph run tree shows workflow params inline when run has key=value args", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-params-"));
+ try {
+ writeFileSync(
+ join(root, "sub.jh"),
+ ["script done_impl = `echo done`", "workflow default(path, mode) {", " run done_impl()", "}", ""].join("\n"),
+ );
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ 'import "sub.jh" as sub',
+ "workflow default() {",
+ ' run sub.default(path="docs/cli.md" mode="strict")',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
+ });
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /workflow default/);
+ // Nested workflow step is shown (rootStepId fix); params inline when runtime sends them
+ assert.match(runResult.stdout, /▸ workflow default/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run tree shows function step; params shown when runtime includes them", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-fn-params-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "script echo_args = \`\`\`",
+ "printf '%s %s\\n' \"$1\" \"$2\"",
+ "\`\`\`",
+ "workflow default() {",
+ ' run echo_args("first" "second")',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
+ });
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /script echo_args/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run tree truncates param values over 32 chars when params present", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-truncate-"));
+ try {
+ const longValue = "a".repeat(40);
+ writeFileSync(
+ join(root, "sub.jh"),
+ ["script done_impl = `echo done`", "workflow default(longparam) {", " run done_impl()", "}", ""].join("\n"),
+ );
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ 'import "sub.jh" as sub',
+ "workflow default() {",
+ ` run sub.default(longparam="${longValue}")`,
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
+ });
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /workflow default/);
+ // When params are shown, long values are truncated to 32 chars + "..."
+ if (/longparam=/.test(runResult.stdout)) {
+ assert.match(runResult.stdout, /longparam="a{32}\.\.\./);
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("buildRunTreeRows expands nested workflow from imported module", () => {
+ const mainSource = [
+ 'import "sub.jh" as sub',
+ "workflow default() {",
+ " run sub.default()",
+ "}",
+ "",
+ ].join("\n");
+ const subSource = [
+ "workflow default() {",
+ ' prompt "nested prompt"',
+ "}",
+ "",
+ ].join("\n");
+ const mainMod = parsejaiph(mainSource, "/fake/main.jh");
+ const subMod = parsejaiph(subSource, "/fake/sub.jh");
+ const importedModules = new Map>([
+ ["sub", subMod],
+ ]);
+ const rows = buildRunTreeRows(mainMod, "workflow default", importedModules, "/fake");
+ assert.equal(rows.length, 3);
+ assert.equal(rows[0].rawLabel, "workflow default");
+ assert.equal(rows[0].isRoot, true);
+ assert.equal(rows[1].rawLabel, "workflow sub.default");
+ assert.equal(rows[2].rawLabel, 'prompt "nested prompt"');
+});
+
+test("jaiph run shows nested workflow subtree and step timing", () => {
+ const rootRaw = mkdtempSync(join(tmpdir(), "jaiph-run-subtree-"));
+ const root = realpathSync(rootRaw);
+ try {
+ writeFileSync(
+ join(root, "sub.jh"),
+ [
+ "workflow default() {",
+ ' prompt "nested prompt"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const mainPath = join(root, "main.jh");
+ writeFileSync(
+ mainPath,
+ [
+ 'import "sub.jh" as sub',
+ "workflow default() {",
+ " run sub.default()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "main.test.jh"),
+ [
+ 'import "main.jh" as m',
+ "",
+ 'test "nested workflow" {',
+ ' mock prompt "mocked"',
+ " const response = run m.default()",
+ ' expect_contain response "mocked"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", join(root, "main.test.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr);
+ assert.match(testResult.stdout, /test\(s\) passed|PASS/);
+ } finally {
+ rmSync(rootRaw, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/helpers.ts b/integration/sample-build/helpers.ts
new file mode 100644
index 00000000..03b78c46
--- /dev/null
+++ b/integration/sample-build/helpers.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import { readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+// Inherited JAIPH_RUNS_DIR (e.g. from a developer shell) would send runs outside each temp
+// workspace; these tests expect artifacts under `/.jaiph/runs`.
+delete process.env.JAIPH_RUNS_DIR;
+
+/** Resolve latest run directory. Layout: runsRoot/YYYY-MM-DD/HH-MM-SS-source/ */
+export function getLatestRunDir(runsRoot: string): string {
+ const dateDirs = readdirSync(runsRoot)
+ .filter((n) => /^\d{4}-\d{2}-\d{2}$/.test(n))
+ .sort();
+ assert.ok(dateDirs.length > 0, "expected at least one date directory under " + runsRoot);
+ const dateDirPath = join(runsRoot, dateDirs[dateDirs.length - 1]);
+ const runDirNames = readdirSync(dateDirPath).sort();
+ assert.ok(runDirNames.length > 0, "expected at least one run directory under " + dateDirPath);
+ return join(dateDirPath, runDirNames[runDirNames.length - 1]);
+}
+
+export function readCombinedRunLogs(runDir: string): { out: string; err: string } {
+ const files = readdirSync(runDir);
+ const out = files
+ .filter((name) => name.endsWith(".out"))
+ .map((name) => readFileSync(join(runDir, name), "utf8"))
+ .join("\n");
+ const err = files
+ .filter((name) => name.endsWith(".err"))
+ .map((name) => readFileSync(join(runDir, name), "utf8"))
+ .join("\n");
+ return { out, err };
+}
diff --git a/integration/sample-build/recover-handle.test.ts b/integration/sample-build/recover-handle.test.ts
new file mode 100644
index 00000000..32d7e557
--- /dev/null
+++ b/integration/sample-build/recover-handle.test.ts
@@ -0,0 +1,401 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { spawnSync } from "node:child_process";
+
+import "./helpers";
+
+// --- recover loop semantics ---
+
+test("recover: success on first attempt skips recover body", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-recover-pass-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "script ok_impl = `echo ok`",
+ "workflow ok() {",
+ " run ok_impl()",
+ "}",
+ "workflow default() {",
+ ' run ok() recover(err) {',
+ ' log "should not run"',
+ ' }',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("recover: one repair loop before success", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-recover-repair-"));
+ try {
+ // Script that fails unless a marker file exists (created by the recover body)
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "script check = `test -f .marker`",
+ "workflow check_wf() {",
+ " run check()",
+ "}",
+ "script fix_impl = `touch .marker`",
+ "workflow fix() {",
+ " run fix_impl()",
+ "}",
+ "workflow default() {",
+ " run check_wf() recover(err) {",
+ " run fix()",
+ " }",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ assert.ok(existsSync(join(root, ".marker")), "repair body should have created marker");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("recover: retry limit exhaustion fails the workflow", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-recover-exhaust-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "config {",
+ " run.recover_limit = 2",
+ "}",
+ "",
+ "script always_fail = `exit 1`",
+ "workflow failing() {",
+ " run always_fail()",
+ "}",
+ "workflow default() {",
+ ' run failing() recover(err) {',
+ ' log "repair attempt"',
+ ' }',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.notEqual(r.status, 0, "should fail after retry limit exhausted");
+ const combined = r.stdout + r.stderr;
+ assert.match(combined, /FAIL/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("recover: retry limit configurable via config", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-recover-limit-"));
+ try {
+ // Counter file incremented by recover body; check script reads and compares.
+ writeFileSync(join(root, ".counter"), "0");
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "config {",
+ " run.recover_limit = 3",
+ "}",
+ "",
+ "script count_impl = ```",
+ 'count=$(cat .counter)',
+ 'if [ "$count" -ge 3 ]; then exit 0; fi',
+ "exit 1",
+ "```",
+ "workflow attempt_wf() {",
+ " run count_impl()",
+ "}",
+ "script bump_impl = ```",
+ 'count=$(cat .counter)',
+ 'echo $(( count + 1 )) > .counter',
+ "```",
+ "workflow bump() {",
+ " run bump_impl()",
+ "}",
+ "workflow default() {",
+ " run attempt_wf() recover(err) {",
+ " run bump()",
+ " }",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// -- Handle async model tests --
+
+test("handle: const capture run async creates handle that resolves on read", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-capture-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ 'script echo_val = `echo "hello"`',
+ "workflow greet() {",
+ " run echo_val()",
+ ' return "hello"',
+ "}",
+ "workflow default() {",
+ " const h = run async greet()",
+ ' log "${h}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: passing handle as arg to run forces resolution", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-resolve-arg-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "workflow producer() {",
+ ' return "produced"',
+ "}",
+ "workflow consumer(val) {",
+ ' log "${val}"',
+ "}",
+ "workflow default() {",
+ " const h = run async producer()",
+ " run consumer(h)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: multi-handle join — multiple async handles passed into another call", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-multi-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "workflow make_a() {",
+ ' return "A"',
+ "}",
+ "workflow make_b() {",
+ ' return "B"',
+ "}",
+ "workflow combine(a, b) {",
+ ' log "${a}-${b}"',
+ "}",
+ "workflow default() {",
+ " const ha = run async make_a()",
+ " const hb = run async make_b()",
+ " run combine(ha, hb)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: workflow exit joins unresolved handles without error", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-join-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ 'script noop = `echo "done"`',
+ "workflow bg() {",
+ " run noop()",
+ "}",
+ "workflow default() {",
+ " const h = run async bg()",
+ ' log "continuing"',
+ " # h is never read — implicit join at exit",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: handles stored in separate vars and resolved when read", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-stored-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "workflow first() {",
+ ' return "1"',
+ "}",
+ "workflow second() {",
+ ' return "2"',
+ "}",
+ "workflow default() {",
+ " const h1 = run async first()",
+ " const h2 = run async second()",
+ " # Both stored, not resolved yet",
+ ' log "${h1}"',
+ ' log "${h2}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: run async foo() recover — handle resolves to success after repair", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-recover-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "script check = `test -f .marker`",
+ "workflow check_wf() {",
+ " run check()",
+ "}",
+ "script fix_impl = `touch .marker`",
+ "workflow fix() {",
+ " run fix_impl()",
+ "}",
+ "workflow default() {",
+ " run async check_wf() recover(err) {",
+ " run fix()",
+ " }",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, /PASS/);
+ assert.ok(existsSync(join(root, ".marker")), "repair body should have created marker");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("handle: run async recover shares retry-limit semantics with non-async recover", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-handle-recover-limit-"));
+ try {
+ writeFileSync(
+ join(root, "main.jh"),
+ [
+ "config {",
+ " run.recover_limit = 2",
+ "}",
+ "",
+ "script always_fail = `exit 1`",
+ "workflow failing() {",
+ " run always_fail()",
+ "}",
+ "workflow default() {",
+ ' run async failing() recover(err) {',
+ ' log "repair attempt"',
+ ' }',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+ assert.notEqual(r.status, 0, "should fail after retry limit exhausted");
+ const combined = r.stdout + r.stderr;
+ assert.match(combined, /FAIL/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/run-core.test.ts b/integration/sample-build/run-core.test.ts
new file mode 100644
index 00000000..e01bb9ae
--- /dev/null
+++ b/integration/sample-build/run-core.test.ts
@@ -0,0 +1,474 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { spawnSync } from "node:child_process";
+
+import { getLatestRunDir, readCombinedRunLogs } from "./helpers";
+
+test("jaiph run compiles and executes workflow with args", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-"));
+ try {
+ const filePath = join(root, "echo.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_arg = \`\`\`",
+ "printf '%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default(name) {",
+ " run print_arg(name)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-run"], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /workflow default/);
+ assert.match(runResult.stdout, /✓ PASS workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run resolves nested managed call arguments", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-nested-args-"));
+ try {
+ const filePath = join(root, "nested_args.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script mkdir_p_simple = ```",
+ 'mkdir -p "$1"',
+ "```",
+ "script jaiph_tmp_dir = ```",
+ 'printf "%s\\n" "$JAIPH_WORKSPACE/.jaiph/tmp"',
+ "```",
+ "workflow default() {",
+ " run mkdir_p_simple(run jaiph_tmp_dir())",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.equal(existsSync(join(root, ".jaiph", "tmp")), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("executable .jh invokes jaiph run semantics", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-exec-jh-"));
+ try {
+ const filePath = join(root, "echo.jh");
+ writeFileSync(
+ filePath,
+ [
+ "#!/usr/bin/env jaiph",
+ "",
+ "script print_exec_arg = \`\`\`",
+ "printf 'exec-arg:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default(name) {",
+ " run print_exec_arg(name)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(filePath, 0o755);
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, filePath, "hello-exec"], {
+ encoding: "utf8",
+ cwd: root,
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /✓ PASS workflow default/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run enables xtrace when JAIPH_DEBUG=true", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-debug-"));
+ try {
+ const filePath = join(root, "debug.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_debug_arg = \`\`\`",
+ "printf 'debug-run:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default(name) {",
+ " run print_debug_arg(name)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-debug"], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DEBUG: "true", JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.ok(runResult.stderr.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run fails when workflow default is missing", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-missing-default-"));
+ try {
+ const filePath = join(root, "pr.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_fallback = \`\`\`",
+ "printf 'fallback:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow main(name) {",
+ " run print_fallback(name)",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-main"], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 1);
+ assert.match(runResult.stderr, /requires workflow 'default'/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run fails fast on command errors inside workflow", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-fail-fast-"));
+ try {
+ const filePath = join(root, "fail-fast.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script always_fail = `false`",
+ "script should_not_run = `echo after-false`",
+ "workflow default() {",
+ " run always_fail()",
+ " run should_not_run()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 1);
+ assert.doesNotMatch(runResult.stdout, /after-false/);
+ assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
+ assert.match(runResult.stderr, /Logs: /);
+ assert.match(runResult.stderr, /Summary: /);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run fails when runtime emits non-xtrace stderr", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-runtime-stderr-"));
+ try {
+ const filePath = join(root, "runtime-stderr.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow default() {",
+ ' log "noop"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run fails when required arg is missing and rule handles it", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-missing-arg-"));
+ try {
+ const filePath = join(root, "missing-arg.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script require_name = \`\`\`",
+ "if [ -z \"$1\" ]; then",
+ " echo \"missing-name\" >&2",
+ " exit 1",
+ "fi",
+ "\`\`\`",
+ "rule name_provided(name) {",
+ " run require_name(name)",
+ "}",
+ "",
+ "workflow default(name) {",
+ " ensure name_provided(name)",
+ ' prompt "Say hello to ${name}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 1);
+ assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
+ assert.match(runResult.stderr, /Logs: /);
+ assert.match(runResult.stderr, /Summary: /);
+ assert.doesNotMatch(runResult.stderr, /unbound variable/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run allows rules to call top-level helper functions in readonly mode", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-rule-helper-fn-"));
+ try {
+ const filePath = join(root, "helpers.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script helper_value = `echo ok`",
+ "script helper_is_ok_impl = \`\`\`",
+ 'test "ok" = "ok"',
+ "\`\`\`",
+ "",
+ "rule helper_is_ok() {",
+ " run helper_is_ok_impl()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure helper_is_ok()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ assert.match(runResult.stdout, /✓ PASS workflow default/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run prints rule tree and fail summary", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-fail-"));
+ try {
+ const filePath = join(root, "fail.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script current_branch_impl = \`\`\`",
+ "echo \"Current branch is not 'main'.\" >&2",
+ "exit 1",
+ "\`\`\`",
+ "rule current_branch() {",
+ " run current_branch_impl()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure current_branch()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
+ });
+
+ assert.equal(runResult.status, 1);
+ assert.match(runResult.stdout, /workflow default/);
+ assert.match(runResult.stdout, /▸ rule current_branch/);
+ assert.match(runResult.stdout, /✗ rule current_branch \(\d+s\)/);
+ assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
+ assert.match(runResult.stderr, /Logs: /);
+ assert.match(runResult.stderr, /Summary: /);
+ assert.match(runResult.stderr, /err: /);
+ assert.match(runResult.stderr, /\.jaiph\/runs\//);
+ const errPathMatch = runResult.stderr.match(/err: (.+)/);
+ assert.equal(Boolean(errPathMatch), true);
+ const errLog = readFileSync(errPathMatch![1], "utf8");
+ assert.match(errLog, /Current branch is not 'main'\./);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run stores prompt output in run logs", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-output:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow default() {",
+ ' prompt "hello from prompt"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ assert.equal(existsSync(runsRoot), true);
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const runDirName = dirname(latestRunDir).startsWith(runsRoot) ? dirname(latestRunDir).slice(runsRoot.length + 1) : "";
+ const dateDirName = runDirName ? runDirName.split("/")[0] : "";
+ assert.match(dateDirName, /^\d{4}-\d{2}-\d{2}$/);
+ assert.match(basename(latestRunDir), /^\d{2}-\d{2}-\d{2}-/);
+ const runFiles = readdirSync(latestRunDir);
+ assert.equal(runFiles.includes("run_summary.jsonl"), true);
+ const { out: promptOut, err: promptErr } = readCombinedRunLogs(latestRunDir);
+ // Node runtime may route prompt transcript differently; keep artifact contract checks.
+ assert.ok(promptOut.length >= 0);
+ assert.ok(promptErr.length >= 0);
+ const summary = readFileSync(join(latestRunDir, "run_summary.jsonl"), "utf8");
+ assert.match(summary, /"type":"STEP_END"/);
+ assert.match(summary, /"kind":"workflow"/);
+ const stepLogFiles = runFiles.filter((name) => name.endsWith(".out"));
+ assert.ok(stepLogFiles.length >= 1);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run stores both reasoning and final answer from stream-json", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-stream-json-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo '{\"type\":\"thinking\",\"text\":\"Plan: check name.\"}'",
+ "echo '{\"type\":\"thinking\",\"text\":\" Then answer.\"}'",
+ "echo '{\"type\":\"result\",\"result\":\"Hello Mike! Fun fact: Mike Shinoda co-founded Linkin Park.\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt-stream-json.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow default() {",
+ ' prompt "hello from prompt"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_AGENT_TRUSTED_WORKSPACE: undefined,
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/run-prompt-agent.test.ts b/integration/sample-build/run-prompt-agent.test.ts
new file mode 100644
index 00000000..6299ed56
--- /dev/null
+++ b/integration/sample-build/run-prompt-agent.test.ts
@@ -0,0 +1,492 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawnSync } from "node:child_process";
+
+import { getLatestRunDir, readCombinedRunLogs } from "./helpers";
+
+test("jaiph run interpolates positional args in prompt text", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-args-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-arg:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt-args.jh");
+ writeFileSync(
+ filePath,
+ [
+ "workflow default(name) {",
+ ' prompt "Say hello to ${name} and mention ${name} again."',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath, "Alice"], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run interpolates named array placeholders in prompt text", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-array-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-array:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt-array.jh");
+ writeFileSync(
+ filePath,
+ [
+ "const DOCS = \"README.md docs/cli.md\"",
+ "workflow default() {",
+ ' prompt """',
+ "Files to keep in sync:",
+ "${DOCS}",
+ '"""',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run applies model from in-file metadata", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-metadata-model-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"model-args:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "config {",
+ ' agent.default_model = "auto"',
+ ' agent.cursor_flags = "--force --sandbox enabled"',
+ "}",
+ "workflow default() {",
+ ' prompt "hello from metadata"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ };
+ delete runEnv.JAIPH_AGENT_CURSOR_FLAGS;
+ delete runEnv.JAIPH_AGENT_MODEL;
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: runEnv,
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run supports agent.command with inline args", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-agent-command-args-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cmd-args:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "config {",
+ ' agent.command = "cursor-agent --force"',
+ "}",
+ "workflow default() {",
+ ' prompt "hello from command args"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run agent.backend = claude uses Claude CLI and captures output", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-claude-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeClaude = join(binDir, "claude");
+ writeFileSync(
+ fakeClaude,
+ [
+ "#!/usr/bin/env bash",
+ "cat",
+ "echo '{\"type\":\"result\",\"result\":\"claude-backend-output '$*'\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeClaude, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_captured = \`\`\`",
+ "printf 'captured:%s\\n' \"$1\"",
+ "\`\`\`",
+ "config {",
+ ' agent.backend = "claude"',
+ ' agent.claude_flags = "--model sonnet-4"',
+ "}",
+ "workflow default() {",
+ ' const result = prompt "hello"',
+ ' run print_captured(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ NODE_NO_WARNINGS: "1",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ };
+ delete runEnv.JAIPH_AGENT_BACKEND;
+ delete runEnv.JAIPH_AGENT_CLAUDE_FLAGS;
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: runEnv,
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(workflowOut, /captured:[\s\S]*claude-backend-output/);
+ assert.match(workflowOut, /captured:[\s\S]*--model sonnet-4/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run agent.backend = claude without claude in PATH fails with clear error", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-claude-missing-"));
+ try {
+ const nodeOnlyBin = join(root, "node-only-bin");
+ mkdirSync(nodeOnlyBin, { recursive: true });
+ symlinkSync(process.execPath, join(nodeOnlyBin, "node"));
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "config {",
+ ' agent.backend = "claude"',
+ "}",
+ "workflow default() {",
+ ' prompt "hello"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${nodeOnlyBin}:/nonexistent`,
+ };
+ delete runEnv.JAIPH_AGENT_BACKEND;
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: runEnv,
+ });
+
+ assert.equal(runResult.status, 1);
+ assert.match(
+ runResult.stderr + runResult.stdout,
+ /agent\.backend is "claude" but the Claude CLI.*not found|JAIPH_AGENT_BACKEND=cursor/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run JAIPH_AGENT_BACKEND env overrides file default", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-env-override-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeCursor = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeCursor,
+ [
+ "#!/usr/bin/env bash",
+ "echo '{\"type\":\"result\",\"result\":\"cursor-from-env\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeCursor, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_out = \`\`\`",
+ "printf 'out:%s\\n' \"$1\"",
+ "\`\`\`",
+ "config {",
+ ' agent.backend = "claude"',
+ "}",
+ "workflow default() {",
+ ' const result = prompt "hi"',
+ ' run print_out(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(workflowOut, /out:[\s\S]*cursor-from-env/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run defaults Cursor trusted workspace to project root", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-trust-default-"));
+ try {
+ mkdirSync(join(root, ".jaiph"), { recursive: true });
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeCursor = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeCursor,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cursor-args:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeCursor, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_out = \`\`\`",
+ "printf 'out:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default() {",
+ ' const result = prompt "hi"',
+ ' run print_out(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const { JAIPH_AGENT_TRUSTED_WORKSPACE: _drop, ...env } = process.env as Record;
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(promptOut, new RegExp(`--trust ${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run JAIPH_AGENT_TRUSTED_WORKSPACE env overrides metadata", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-trust-env-override-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeCursor = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeCursor,
+ [
+ "#!/usr/bin/env bash",
+ "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cursor-args:$*\\\"}\"",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeCursor, 0o755);
+
+ const filePath = join(root, "prompt.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_out = \`\`\`",
+ "printf 'out:%s\\n' \"$1\"",
+ "\`\`\`",
+ "config {",
+ ' agent.trusted_workspace = ".jaiph/.."',
+ "}",
+ "workflow default() {",
+ ' const result = prompt "hi"',
+ ' run print_out(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_AGENT_TRUSTED_WORKSPACE: "/tmp/jaiph-explicit-trust",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(promptOut, /--trust \/tmp\/jaiph-explicit-trust/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/test-advanced.test.ts b/integration/sample-build/test-advanced.test.ts
new file mode 100644
index 00000000..5744850b
--- /dev/null
+++ b/integration/sample-build/test-advanced.test.ts
@@ -0,0 +1,491 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawnSync } from "node:child_process";
+import { parsejaiph } from "../../src/parser";
+import { walkTestFiles } from "../../src/transpiler";
+
+import { getLatestRunDir, readCombinedRunLogs } from "./helpers";
+
+test("jaiph run prompt capture: variable accessible in subsequent shell step", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-capture-"));
+ try {
+ // Anchor workspace here: a parent of TMPDIR may contain `.jaiph`, which would otherwise
+ // become JAIPH_WORKSPACE and send runs outside this temp root.
+ mkdirSync(join(root, ".jaiph"), { recursive: true });
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo '{\"type\":\"result\",\"result\":\"agent-summary\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "capture.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_captured = \`\`\`",
+ "printf 'captured:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default() {",
+ ' const result = prompt "Summarize"',
+ ' run print_captured(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(workflowOut, /captured:[\s\S]*agent-summary/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph run prompt capture stores only final answer in assigned variable", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-capture-final-only-"));
+ try {
+ mkdirSync(join(root, ".jaiph"), { recursive: true });
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeAgent = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeAgent,
+ [
+ "#!/usr/bin/env bash",
+ "echo '{\"type\":\"thinking\",\"text\":\"Plan: inspect data.\"}'",
+ "echo '{\"type\":\"result\",\"result\":\"final-only-value\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeAgent, 0o755);
+
+ const filePath = join(root, "capture_final_only.jh");
+ writeFileSync(
+ filePath,
+ [
+ "script print_captured = \`\`\`",
+ "printf 'captured:%s\\n' \"$1\"",
+ "\`\`\`",
+ "workflow default() {",
+ ' const result = prompt "Summarize"',
+ ' run print_captured(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const runResult = spawnSync("node", [cliPath, "run", filePath], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ JAIPH_AGENT_BACKEND: "cursor",
+ JAIPH_DOCKER_ENABLED: "false",
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(runResult.status, 0, runResult.stderr);
+ const runsRoot = join(root, ".jaiph/runs");
+ const latestRunDir = getLatestRunDir(runsRoot);
+ const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
+ assert.match(workflowOut, /captured:[\s\S]*final-only-value/);
+ assert.doesNotMatch(workflowOut, /captured:[^\n]*Plan: inspect data\./);
+
+ const { out: promptOut } = readCombinedRunLogs(latestRunDir);
+ assert.ok(promptOut.length >= 0);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test with agent.backend = claude uses mock and does not invoke claude", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-backend-claude-mock-"));
+ try {
+ writeFileSync(
+ join(root, "flow.jh"),
+ [
+ "script print_got = \`\`\`",
+ "printf 'got:%s\\n' \"$1\"",
+ "\`\`\`",
+ "config {",
+ ' agent.backend = "claude"',
+ "}",
+ "workflow default() {",
+ ' const result = prompt "ask"',
+ ' run print_got(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "flow.test.jh"),
+ [
+ 'import "flow.jh" as w',
+ "",
+ 'test "mock overrides backend" {',
+ ' mock prompt "mock-response"',
+ " const out = run w.default()",
+ ' expect_contain out "mock-response"',
+ ' expect_contain out "got:"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: { ...process.env, PATH: `${dirname(process.execPath)}:/bin:/usr/bin:/nonexistent` },
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
+ assert.match(testResult.stdout + testResult.stderr, /mock-response|PASS|passed/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test when prompt is not mocked runs selected backend", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-unmocked-backend-"));
+ try {
+ const binDir = join(root, "bin");
+ mkdirSync(binDir, { recursive: true });
+ const fakeCursor = join(binDir, "cursor-agent");
+ writeFileSync(
+ fakeCursor,
+ [
+ "#!/usr/bin/env bash",
+ "echo '{\"type\":\"result\",\"result\":\"backend-ran\"}'",
+ "",
+ ].join("\n"),
+ );
+ chmodSync(fakeCursor, 0o755);
+
+ writeFileSync(
+ join(root, "flow.jh"),
+ [
+ "script print_got = \`\`\`",
+ "printf 'got:%s\\n' \"$1\"",
+ "\`\`\`",
+ "config {",
+ ' agent.backend = "cursor"',
+ "}",
+ "workflow default() {",
+ ' const result = prompt "ask"',
+ ' run print_got(result)',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "flow.test.jh"),
+ [
+ 'import "flow.jh" as w',
+ "",
+ 'test "no mock uses backend" {',
+ " const out = run w.default()",
+ ' expect_contain out "backend-ran"',
+ ' expect_contain out "got:"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
+ },
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
+ assert.match(testResult.stdout + testResult.stderr, /backend-ran|PASS|passed/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test passes for workflow using ensure only with mocks", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-ensure-only-"));
+ try {
+ writeFileSync(
+ join(root, "ensure_only.jh"),
+ [
+ "script ready_impl = `echo ok`",
+ "rule ready() {",
+ " run ready_impl()",
+ "}",
+ "",
+ "workflow default() {",
+ " ensure ready()",
+ ' return "ready-ok"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "ensure_only.test.jh"),
+ [
+ 'import "ensure_only.jh" as e',
+ "",
+ 'test "workflow default" {',
+ " const response = run e.default()",
+ ' expect_contain response "ready-ok"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "ensure_only.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr);
+ assert.match(testResult.stdout, /test\(s\) passed|PASS/);
+ assert.match(testResult.stdout, /workflow default/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("parser parses test blocks in *.test.jh file", () => {
+ const source = [
+ 'import "workflow.jh" as w',
+ '',
+ 'test "runs default" {',
+ ' const response = run w.default()',
+ ' expect_contain response "PASS"',
+ "}",
+ "",
+ ].join("\n");
+ const mod = parsejaiph(source, "/fake/workflow.test.jh");
+ assert.ok(mod.tests);
+ assert.equal(mod.tests!.length, 1);
+ assert.equal(mod.tests![0].description, "runs default");
+ assert.equal(mod.tests![0].steps.length, 2);
+ assert.equal(mod.tests![0].steps[0].type, "test_run_workflow");
+ if (mod.tests![0].steps[0].type === "test_run_workflow") {
+ assert.equal(mod.tests![0].steps[0].captureName, "response");
+ assert.equal(mod.tests![0].steps[0].workflowRef, "w.default");
+ }
+ assert.equal(mod.tests![0].steps[1].type, "test_expect_contain");
+ if (mod.tests![0].steps[1].type === "test_expect_contain") {
+ assert.equal(mod.tests![0].steps[1].variable, "response");
+ assert.equal(mod.tests![0].steps[1].substring, "PASS");
+ }
+});
+
+test("parser parses mock workflow, rule, and script in test block", () => {
+ const source = [
+ 'import "app.jh" as app',
+ "",
+ 'test "isolated orchestration" {',
+ " mock workflow app.build() {",
+ ' log "build ok"',
+ ' return "done"',
+ " }",
+ "",
+ " mock rule app.policy_check() {",
+ ' return "blocked"',
+ " }",
+ "",
+ " mock script app.changed_files() {",
+ ' echo "a.ts"',
+ ' echo "b.ts"',
+ " }",
+ "",
+ " const out = run app.default()",
+ ' expect_contain out "blocked"',
+ "}",
+ "",
+ ].join("\n");
+ const mod = parsejaiph(source, "/fake/app.test.jh");
+ assert.ok(mod.tests);
+ assert.equal(mod.tests!.length, 1);
+ assert.equal(mod.tests![0].description, "isolated orchestration");
+ const steps = mod.tests![0].steps;
+ assert.equal(steps[0].type, "test_mock_workflow");
+ if (steps[0].type === "test_mock_workflow") {
+ assert.equal(steps[0].ref, "app.build");
+ assert.deepEqual(steps[0].params, []);
+ assert.equal(steps[0].steps.length, 2);
+ }
+ assert.equal(steps[1].type, "blank_line");
+ assert.equal(steps[2].type, "test_mock_rule");
+ if (steps[2].type === "test_mock_rule") {
+ assert.equal(steps[2].ref, "app.policy_check");
+ assert.deepEqual(steps[2].params, []);
+ assert.equal(steps[2].steps.length, 1);
+ }
+ assert.equal(steps[3].type, "blank_line");
+ assert.equal(steps[4].type, "test_mock_script");
+ if (steps[4].type === "test_mock_script") {
+ assert.equal(steps[4].ref, "app.changed_files");
+ assert.ok(steps[4].body.includes('echo "a.ts"'));
+ }
+ assert.equal(steps[5].type, "blank_line");
+ assert.equal(steps[6].type, "test_run_workflow");
+ assert.equal(steps[7].type, "test_expect_contain");
+});
+
+test("parser ignores test keyword in non-test file", () => {
+ const source = [
+ "workflow default() {",
+ ' echo "hello"',
+ "}",
+ "",
+ ].join("\n");
+ const mod = parsejaiph(source, "/fake/main.jh");
+ assert.equal(mod.tests, undefined);
+});
+
+test("jaiph test runs *.test.jh with mock workflow, rule, and script", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-symbols-"));
+ try {
+ writeFileSync(
+ join(root, "app.jh"),
+ [
+ "script policy_check_impl = `echo real-policy`",
+ "rule policy_check() {",
+ " run policy_check_impl()",
+ "}",
+ "script changed_files = `echo real_files`",
+ "script build_impl = \`\`\`",
+ 'echo "real build"',
+ "\`\`\`",
+ "workflow build() {",
+ " run build_impl()",
+ "}",
+ "workflow default() {",
+ " ensure policy_check()",
+ " run build()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "app.test.jh"),
+ [
+ 'import "app.jh" as app',
+ "",
+ 'test "isolated orchestration" {',
+ " mock workflow app.build() {",
+ ' log "build ok"',
+ ' return "build ok"',
+ " }",
+ "",
+ " mock rule app.policy_check() {",
+ ' return "policy ok"',
+ " }",
+ "",
+ " mock script app.changed_files() {",
+ ' echo "a.ts"',
+ ' echo "b.ts"',
+ " }",
+ "",
+ " const out = run app.default()",
+ ' expect_contain out "build ok"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const result = spawnSync("node", [cliPath, "test", join(root, "app.test.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+ assert.equal(result.status, 0, result.stderr + "\n" + result.stdout);
+ assert.match(result.stdout, /test\(s\) passed|PASS/);
+ assert.match(result.stdout, /isolated orchestration/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test runs *.test.jh file with mocks", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-native-test-"));
+ try {
+ writeFileSync(
+ join(root, "flow.jh"),
+ [
+ "workflow default() {",
+ ' prompt "please greet"',
+ ' return "done"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "flow.test.jh"),
+ [
+ 'import "flow.jh" as f',
+ "",
+ 'test "captures output" {',
+ ' mock prompt "mocked"',
+ " const out = run f.default()",
+ ' expect_contain out "done"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const result = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+ assert.equal(result.status, 0, result.stderr + "\n" + result.stdout);
+ assert.match(result.stdout, /test\(s\) passed|PASS/);
+ assert.match(result.stdout, /captures output/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("walkTestFiles discovers *.test.jh in directory", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-walk-test-"));
+ try {
+ writeFileSync(join(root, "a.test.jh"), "test \"t\" { }\n");
+ writeFileSync(join(root, "b.jh"), "workflow default() { }\n");
+ const files = walkTestFiles(root);
+ assert.equal(files.length, 1);
+ assert.ok(files.some((f) => f.endsWith("a.test.jh")));
+ assert.ok(!files.some((f) => f.endsWith("b.jh")));
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/integration/sample-build/test-framework.test.ts b/integration/sample-build/test-framework.test.ts
new file mode 100644
index 00000000..1a909883
--- /dev/null
+++ b/integration/sample-build/test-framework.test.ts
@@ -0,0 +1,253 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawnSync } from "node:child_process";
+
+import "./helpers";
+
+test("jaiph test runs workflow with mocked prompts", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-"));
+ try {
+ writeFileSync(
+ join(root, "hello.jh"),
+ [
+ "workflow default() {",
+ ' prompt "Please greet the user"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "hello.test.jh"),
+ [
+ 'import "hello.jh" as h',
+ "",
+ 'test "workflow default" {',
+ ' mock prompt "Mocked greeting output"',
+ " const response = run h.default()",
+ ' expect_contain response "Mocked greeting output"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "hello.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr);
+ assert.match(testResult.stdout, /test\(s\) passed|PASS/);
+ assert.match(testResult.stdout, /test happy path|workflow default/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test fails when no mock matches prompt", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-no-mock-"));
+ try {
+ writeFileSync(
+ join(root, "hello.jh"),
+ [
+ "workflow default() {",
+ ' prompt "Please greet the user"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "hello.test.jh"),
+ [
+ 'import "hello.jh" as h',
+ "",
+ 'test "no mock for prompt" {',
+ " const response = run h.default()",
+ ' expect_contain response "no mock"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "hello.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ PATH: `${dirname(process.execPath)}:/bin:/usr/bin`,
+ },
+ });
+
+ assert.equal(testResult.status, 1, "expected test run to fail when prompt has no mock");
+ assert.match(testResult.stderr + testResult.stdout, /expect_contain failed|FAIL|no mock|not found|command not found/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test fails when non-test file is passed", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-missing-mock-"));
+ try {
+ writeFileSync(
+ join(root, "hello.jh"),
+ [
+ "workflow default() {",
+ ' prompt "hello"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "hello.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+ assert.equal(testResult.status, 1);
+ assert.match(testResult.stderr, /\.test\.jh|inline mock/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test captures mock response into variable and variable is available in subsequent step", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-prompt-capture-"));
+ try {
+ writeFileSync(
+ join(root, "capture.jh"),
+ [
+ "workflow default() {",
+ ' const result = prompt "Please greet the user"',
+ ' return "${result}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "capture.test.jh"),
+ [
+ 'import "capture.jh" as c',
+ "",
+ 'test "capture mock" {',
+ ' mock prompt "CAPTURED_MOCK_OUTPUT"',
+ " const response = run c.default()",
+ ' expect_contain response "CAPTURED_MOCK_OUTPUT"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "capture.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr);
+ assert.match(testResult.stdout, /test\(s\) passed|PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test inline mock prompt block with if/elif/else and first-match", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-block-"));
+ try {
+ writeFileSync(
+ join(root, "multi_prompt.jh"),
+ [
+ "workflow default() {",
+ ' const a = prompt "greet"',
+ ' const b = prompt "bye"',
+ ' return "${a} ${b}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "multi_prompt.test.jh"),
+ [
+ 'import "multi_prompt.jh" as m',
+ "",
+ 'test "mock block first-match" {',
+ " mock prompt {",
+ ' /greet/ => "hello"',
+ ' /bye/ => "goodbye"',
+ ' _ => "default"',
+ " }",
+ " const out = run m.default()",
+ ' expect_contain out "hello"',
+ ' expect_contain out "goodbye"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "multi_prompt.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: process.env,
+ });
+
+ assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
+ assert.match(testResult.stdout, /test\(s\) passed|PASS/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("jaiph test fails when no mock branch matches and no wildcard", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-no-else-"));
+ try {
+ writeFileSync(
+ join(root, "single.jh"),
+ [
+ "workflow default() {",
+ ' const result = prompt "unmatched prompt text"',
+ ' return "${result}"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+ writeFileSync(
+ join(root, "single.test.jh"),
+ [
+ 'import "single.jh" as s',
+ "",
+ 'test "no wildcard arm" {',
+ " mock prompt {",
+ ' /other/ => "never"',
+ " }",
+ " const out = run s.default()",
+ ' expect_contain out "x"',
+ "}",
+ "",
+ ].join("\n"),
+ );
+
+ const cliPath = join(process.cwd(), "dist/src/cli.js");
+ const testResult = spawnSync("node", [cliPath, "test", "single.test.jh"], {
+ encoding: "utf8",
+ cwd: root,
+ env: {
+ ...process.env,
+ PATH: `${dirname(process.execPath)}:/bin:/usr/bin`,
+ },
+ });
+
+ assert.equal(testResult.status, 1, "expected test to fail when no branch matches, no wildcard, and no backend in PATH");
+ assert.match(
+ testResult.stderr + testResult.stdout,
+ /workflow exited with status|no mock matched|no branch matched|expect_contain failed|FAIL|not found|command not found/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/test/signal-lifecycle.test.ts b/integration/signal-lifecycle.test.ts
similarity index 90%
rename from test/signal-lifecycle.test.ts
rename to integration/signal-lifecycle.test.ts
index 272f9dd2..79683879 100644
--- a/test/signal-lifecycle.test.ts
+++ b/integration/signal-lifecycle.test.ts
@@ -146,7 +146,12 @@ async function runInterruptTest(
const child = spawn("node", [cliPath, "run", workflowPath], {
stdio: "pipe",
cwd: root,
- env: { ...process.env, JAIPH_UNSAFE: "true" }, // disable Docker so exit-within-5s assertion is reliable (CI=true no longer disables)
+ env: {
+ ...process.env,
+ JAIPH_UNSAFE: "true",
+ JAIPH_DOCKER_ENABLED: "false",
+ JAIPH_RUNS_DIR: join(root, ".jaiph/runs"),
+ },
});
const exitPromise = new Promise<{ code: number | null; signal: string | null }>((resolve) => {
@@ -155,14 +160,18 @@ async function runInterruptTest(
});
});
- // Let the workflow start (bash + sleep running)
- await new Promise((r) => setTimeout(r, 600));
-
+ // Let the workflow start (bash + sleep running). Detached runner reparenting can
+ // delay visible children; poll briefly instead of a single fixed sleep.
const nodePid = child.pid;
assert.ok(nodePid != null, "spawned node process must have a pid");
- const ourJaiphRunPids = findChildPidsMatching(nodePid, "jaiph-run");
- const fallbackChildPids = findChildPids(nodePid);
- const trackedPids = ourJaiphRunPids.length > 0 ? ourJaiphRunPids : fallbackChildPids;
+ let trackedPids: number[] = [];
+ for (let i = 0; i < 50; i += 1) {
+ await new Promise((r) => setTimeout(r, 100));
+ const ourJaiphRunPids = findChildPidsMatching(nodePid, "jaiph-run");
+ const fallbackChildPids = findChildPids(nodePid);
+ trackedPids = ourJaiphRunPids.length > 0 ? ourJaiphRunPids : fallbackChildPids;
+ if (trackedPids.length >= 1) break;
+ }
assert.ok(
trackedPids.length >= 1,
"our node process should have spawned at least one child process",
diff --git a/test/tty-running-timer.test.ts b/integration/tty-running-timer.test.ts
similarity index 100%
rename from test/tty-running-timer.test.ts
rename to integration/tty-running-timer.test.ts
diff --git a/package.json b/package.json
index 3e762c28..def844bd 100644
--- a/package.json
+++ b/package.json
@@ -18,9 +18,9 @@
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('src/runtime','dist/src/runtime',{recursive:true})\" && node -e \"require('node:fs').cpSync('runtime/overlay-run.sh','dist/src/runtime/overlay-run.sh')\"",
"build:standalone": "npm run build && node -e \"const fs=require('node:fs'); fs.cpSync('dist/src/runtime','dist/runtime',{recursive:true});\" && bun build --compile ./src/cli.ts --outfile ./dist/jaiph",
- "test:compiler": "npm run build && node --test dist/src/compiler-test-runner.js",
- "test:golden-ast": "npm run build && node --test dist/src/golden-ast-runner.js",
- "test": "npm run clean && npm run build && JAIPH_UNSAFE=true NODE_OPTIONS='--max-old-space-size=32768 --enable-source-maps' node --test dist/test/*.test.js $(find dist/src -name '*.test.js' -o -name '*.acceptance.test.js') dist/src/compiler-test-runner.js dist/src/golden-ast-runner.js",
+ "test:compiler": "npm run build && node --test dist/test-infra/compiler-test-runner.js",
+ "test:golden-ast": "npm run build && node --test dist/test-infra/golden-ast-runner.js",
+ "test": "npm run clean && npm run build && JAIPH_UNSAFE=true NODE_OPTIONS='--max-old-space-size=32768 --enable-source-maps' node --test $(find dist/integration -name '*.test.js') $(find dist/src -name '*.test.js' -o -name '*.acceptance.test.js') dist/test-infra/compiler-test-runner.js dist/test-infra/golden-ast-runner.js",
"test:acceptance:compiler": "npm run build && node --test $(find dist/src -name '*.acceptance.test.js')",
"test:acceptance:runtime": "bash ./e2e/test_all.sh",
"test:acceptance": "npm run test:acceptance:compiler && npm run test:acceptance:runtime",
diff --git a/playwright.config.ts b/playwright.config.ts
index 1af2aca0..bb847cee 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,8 +1,8 @@
import { defineConfig } from '@playwright/test';
-import { LOCAL_DOCS_SITE } from './tests/e2e-samples/docs-site';
+import { LOCAL_DOCS_SITE } from './e2e/playwright/docs-site';
export default defineConfig({
- testDir: './tests/e2e-samples',
+ testDir: './e2e/playwright',
timeout: 60_000,
use: {
baseURL: LOCAL_DOCS_SITE,
diff --git a/src/cli/run/env.ts b/src/cli/run/env.ts
index 0837ec15..687ab3bc 100644
--- a/src/cli/run/env.ts
+++ b/src/cli/run/env.ts
@@ -10,7 +10,6 @@ const LOCKED_ENV_KEYS = [
"JAIPH_AGENT_CLAUDE_FLAGS",
"JAIPH_RUNS_DIR",
"JAIPH_DEBUG",
- "JAIPH_INBOX_PARALLEL",
] as const;
/**
@@ -61,9 +60,6 @@ export function resolveRuntimeEnv(
if (env.JAIPH_DEBUG === undefined && effectiveConfig.run?.debug === true) {
env.JAIPH_DEBUG = "true";
}
- if (env.JAIPH_INBOX_PARALLEL === undefined && effectiveConfig.run?.inboxParallel === true) {
- env.JAIPH_INBOX_PARALLEL = "true";
- }
env.JAIPH_SOURCE_FILE = basename(inputAbs);
// JAIPH_STDLIB is no longer used; clean it from inherited env.
delete env.JAIPH_STDLIB;
@@ -79,9 +75,6 @@ export function resolveRuntimeEnv(
// `jaiph run` always builds scripts under that run's output dir; inherited JAIPH_SCRIPTS would shadow
// the per-module default in the emitted `export JAIPH_SCRIPTS="${JAIPH_SCRIPTS:-$(cd ...)}"`.
delete env.JAIPH_SCRIPTS;
- // Same for the workflow module path: a parent shell or nested tool may export this; the emitted
- // module only sets JAIPH_RUN_STEP_MODULE when unset, so a stale path would break run-step-exec.
- delete env.JAIPH_RUN_STEP_MODULE;
// Strip stale JAIPH_LIB from a parent shell (removed from the product; scripts use JAIPH_WORKSPACE).
delete env.JAIPH_LIB;
diff --git a/src/cli/run/progress.test.ts b/src/cli/run/progress.test.ts
index 51d4d2da..92ab843a 100644
--- a/src/cli/run/progress.test.ts
+++ b/src/cli/run/progress.test.ts
@@ -419,7 +419,7 @@ test("collectWorkflowChildren: run step with single catch includes recovery item
{
type: "run",
workflow: { value: "deploy", loc: { line: 2, col: 3 } },
- recover: {
+ catch: {
single: { type: "log", message: "recovering", loc: { line: 3, col: 5 } },
bindings: { failure: "err" },
},
@@ -444,7 +444,7 @@ test("collectWorkflowChildren: run step with block catch includes all recovery i
{
type: "run",
workflow: { value: "deploy", loc: { line: 2, col: 3 } },
- recover: {
+ catch: {
block: [
{ type: "log", message: "retrying", loc: { line: 3, col: 5 } },
{ type: "run", workflow: { value: "fallback", loc: { line: 4, col: 5 } } },
@@ -473,7 +473,7 @@ test("collectWorkflowChildren: ensure step with single catch includes recovery i
{
type: "ensure",
ref: { value: "check", loc: { line: 2, col: 3 } },
- recover: {
+ catch: {
single: { type: "run", workflow: { value: "fix_it", loc: { line: 3, col: 5 } } },
bindings: { failure: "err" },
},
@@ -498,7 +498,7 @@ test("collectWorkflowChildren: ensure step with block catch includes all recover
{
type: "ensure",
ref: { value: "check", loc: { line: 2, col: 3 } },
- recover: {
+ catch: {
block: [
{ type: "log", message: "check failed", loc: { line: 3, col: 5 } },
{ type: "fail", message: "unrecoverable", loc: { line: 4, col: 5 } },
@@ -1467,7 +1467,7 @@ test("buildRunTreeRows: run with catch block shows recovery steps in tree", () =
{
type: "run",
workflow: { value: "risky", loc: { line: 2, col: 3 } },
- recover: {
+ catch: {
bindings: { failure: "err" },
block: [
{ type: "log", message: "recovering", loc: { line: 4, col: 5 } },
diff --git a/src/cli/run/progress.ts b/src/cli/run/progress.ts
index 6746a430..86aeaaa3 100644
--- a/src/cli/run/progress.ts
+++ b/src/cli/run/progress.ts
@@ -81,13 +81,13 @@ export function collectWorkflowChildren(
const arr: Array<{ label: string; nested?: string; stepFunc?: string }> = [
{ label: `${asyncPrefix}workflow ${wf}`, nested: wf, stepFunc },
];
- if (s.recoverLoop) {
- const steps = "single" in s.recoverLoop ? [s.recoverLoop.single] : s.recoverLoop.block;
+ if (s.recover) {
+ const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
for (const r of steps) {
arr.push(...stepToItems(r));
}
- } else if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ } else if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
for (const r of steps) {
arr.push(...stepToItems(r));
}
@@ -110,8 +110,8 @@ export function collectWorkflowChildren(
const arr: Array<{ label: string; nested?: string; stepFunc?: string }> = [
{ label: `rule ${ref}`, stepFunc },
];
- if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
for (const r of steps) {
arr.push(...stepToItems(r));
}
diff --git a/src/cli/run/resolve-env.test.ts b/src/cli/run/resolve-env.test.ts
index 5f3a00c7..b602b6b3 100644
--- a/src/cli/run/resolve-env.test.ts
+++ b/src/cli/run/resolve-env.test.ts
@@ -68,10 +68,8 @@ test("resolveRuntimeEnv: marks env-provided keys as locked", () => {
test("resolveRuntimeEnv: cleans transient keys", () => {
const saved = process.env.JAIPH_RUN_DIR;
- const savedModule = process.env.JAIPH_RUN_STEP_MODULE;
const savedMeta = process.env.JAIPH_META_FILE;
process.env.JAIPH_RUN_DIR = "/old/run";
- process.env.JAIPH_RUN_STEP_MODULE = "/stale/module.sh";
process.env.JAIPH_META_FILE = "/stale/meta.txt";
try {
const config: JaiphConfig = {};
@@ -81,18 +79,12 @@ test("resolveRuntimeEnv: cleans transient keys", () => {
assert.equal(env.BASH_ENV, undefined);
assert.equal(env.JAIPH_PRECEDING_FILES, undefined);
assert.equal(env.JAIPH_RUN_SUMMARY_FILE, undefined);
- assert.equal(env.JAIPH_RUN_STEP_MODULE, undefined);
} finally {
if (saved !== undefined) {
process.env.JAIPH_RUN_DIR = saved;
} else {
delete process.env.JAIPH_RUN_DIR;
}
- if (savedModule !== undefined) {
- process.env.JAIPH_RUN_STEP_MODULE = savedModule;
- } else {
- delete process.env.JAIPH_RUN_STEP_MODULE;
- }
if (savedMeta !== undefined) {
process.env.JAIPH_META_FILE = savedMeta;
} else {
diff --git a/src/cli/shared/errors.test.ts b/src/cli/shared/errors.test.ts
index bb5f215c..1273f7e2 100644
--- a/src/cli/shared/errors.test.ts
+++ b/src/cli/shared/errors.test.ts
@@ -238,6 +238,38 @@ test("failedStepArtifactPaths: empty when summary missing failed STEP_END", () =
}
});
+test("failedStepArtifactPaths: uses last failed STEP_END when several have non-zero status", () => {
+ const dir = mkdtempSync(join(tmpdir(), "jaiph-failed-paths-last-"));
+ try {
+ const noiseOut = join(dir, "010_noise_fail.out");
+ const terminalOut = join(dir, "020_terminal_fail.out");
+ writeFileSync(
+ join(dir, "summary.jsonl"),
+ [
+ JSON.stringify({
+ type: "STEP_END",
+ status: 1,
+ out_file: noiseOut,
+ err_file: "",
+ out_content: "earlier spurious failure",
+ }),
+ JSON.stringify({
+ type: "STEP_END",
+ status: 1,
+ out_file: terminalOut,
+ err_file: "",
+ out_content: "terminal_failure_body",
+ }),
+ ].join("\n") + "\n",
+ );
+ const summaryPath = join(dir, "summary.jsonl");
+ assert.deepEqual(failedStepArtifactPaths(summaryPath), { out: terminalOut });
+ assert.equal(readFailedStepOutput(summaryPath), "terminal_failure_body");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test("failedStepArtifactPaths: maps to failed step out file, not lexicographically latest in run dir", () => {
const dir = mkdtempSync(join(tmpdir(), "jaiph-failed-paths-latest-"));
try {
diff --git a/src/cli/shared/errors.ts b/src/cli/shared/errors.ts
index 4b68063d..b278bf4c 100644
--- a/src/cli/shared/errors.ts
+++ b/src/cli/shared/errors.ts
@@ -119,12 +119,14 @@ type FailedStepSummaryRecord = {
err_content?: string;
};
-function readFirstFailedStepSummary(summaryPath: string): FailedStepSummaryRecord | null {
+/** Last failed step wins so the excerpt matches terminal failure after `catch`/retries or stray earlier records. */
+function readLastFailedStepSummary(summaryPath: string): FailedStepSummaryRecord | null {
if (!existsSync(summaryPath)) {
return null;
}
try {
const lines = readFileSync(summaryPath, "utf8").split(/\r?\n/).filter(Boolean);
+ let last: FailedStepSummaryRecord | null = null;
for (const line of lines) {
const parsed = JSON.parse(line) as {
type?: string;
@@ -137,22 +139,23 @@ function readFirstFailedStepSummary(summaryPath: string): FailedStepSummaryRecor
if (parsed.type !== "STEP_END" || parsed.status === 0) {
continue;
}
- return {
+ last = {
out_file: typeof parsed.out_file === "string" ? parsed.out_file : "",
err_file: typeof parsed.err_file === "string" ? parsed.err_file : "",
out_content: typeof parsed.out_content === "string" ? parsed.out_content : undefined,
err_content: typeof parsed.err_content === "string" ? parsed.err_content : undefined,
};
}
+ return last;
} catch {
// ignore parse/read errors
}
return null;
}
-/** Artifact paths from the first failed STEP_END in the run summary (not lexicographic "latest" in the run dir). */
+/** Artifact paths from the last failed STEP_END in the run summary (not lexicographic "latest" in the run dir). */
export function failedStepArtifactPaths(summaryPath: string): { out?: string; err?: string } {
- const rec = readFirstFailedStepSummary(summaryPath);
+ const rec = readLastFailedStepSummary(summaryPath);
if (!rec) {
return {};
}
@@ -167,7 +170,7 @@ export function failedStepArtifactPaths(summaryPath: string): { out?: string; er
}
export function readFailedStepOutput(summaryPath: string): string | null {
- const rec = readFirstFailedStepSummary(summaryPath);
+ const rec = readLastFailedStepSummary(summaryPath);
if (!rec) {
return null;
}
diff --git a/src/config.ts b/src/config.ts
index b044896b..5819bf60 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -12,7 +12,6 @@ export type JaiphConfig = {
run?: {
debug?: boolean;
logsDir?: string;
- inboxParallel?: boolean;
};
};
diff --git a/src/format/emit.ts b/src/format/emit.ts
index 3aaef839..bbc70329 100644
--- a/src/format/emit.ts
+++ b/src/format/emit.ts
@@ -155,9 +155,6 @@ function emitConfigKeyLines(meta: WorkflowMetadata, key: string, pad: string): s
case "run.logs_dir":
if (meta.run?.logsDir === undefined) return [];
return [`${pad}run.logs_dir = "${meta.run.logsDir}"`];
- case "run.inbox_parallel":
- if (meta.run?.inboxParallel === undefined) return [];
- return [`${pad}run.inbox_parallel = ${meta.run.inboxParallel}`];
case "run.recover_limit":
if (meta.run?.recoverLimit === undefined) return [];
return [`${pad}run.recover_limit = ${meta.run.recoverLimit}`];
@@ -212,7 +209,6 @@ function emitConfig(meta: WorkflowMetadata, pad: string): string {
if (meta.run) {
if (meta.run.debug !== undefined) lines.push(`${pad}run.debug = ${meta.run.debug}`);
if (meta.run.logsDir !== undefined) lines.push(`${pad}run.logs_dir = "${meta.run.logsDir}"`);
- if (meta.run.inboxParallel !== undefined) lines.push(`${pad}run.inbox_parallel = ${meta.run.inboxParallel}`);
if (meta.run.recoverLimit !== undefined) lines.push(`${pad}run.recover_limit = ${meta.run.recoverLimit}`);
}
if (meta.runtime) {
@@ -287,28 +283,6 @@ function emitScript(script: ScriptDef, _pad: string, exported: boolean): string
return lines.join("\n");
}
-/** Single-line `config { agent.backend = "…" }` when that is the only workflow metadata field. */
-function emitCompactInlineWorkflowConfig(meta: WorkflowMetadata): string | null {
- if (meta.run !== undefined || meta.runtime !== undefined) return null;
- const seq = meta.configBodySequence;
- if (seq?.length) {
- if (seq.length !== 1 || seq[0].kind !== "assign" || seq[0].key !== "agent.backend") {
- return null;
- }
- }
- if (!meta.agent) return null;
- const a = meta.agent;
- const fieldCount =
- (a.defaultModel !== undefined ? 1 : 0) +
- (a.command !== undefined ? 1 : 0) +
- (a.backend !== undefined ? 1 : 0) +
- (a.trustedWorkspace !== undefined ? 1 : 0) +
- (a.cursorFlags !== undefined ? 1 : 0) +
- (a.claudeFlags !== undefined ? 1 : 0);
- if (fieldCount !== 1 || a.backend === undefined) return null;
- return `config { agent.backend = "${a.backend}" }`;
-}
-
function emitWorkflow(wf: WorkflowDef, pad: string, exported: boolean): string {
const lines: string[] = [];
lines.push(...emitComments(wf.comments));
@@ -318,14 +292,9 @@ function emitWorkflow(wf: WorkflowDef, pad: string, exported: boolean): string {
lines.push(`${prefix}workflow ${wf.name}${paramStr} {`);
if (wf.metadata) {
- const compact = emitCompactInlineWorkflowConfig(wf.metadata);
- if (compact) {
- lines.push(`${pad}${compact}`);
- } else {
- const configLines = emitConfig(wf.metadata, pad);
- for (const cl of configLines.split("\n")) {
- lines.push(`${pad}${cl}`);
- }
+ const configLines = emitConfig(wf.metadata, pad);
+ for (const cl of configLines.split("\n")) {
+ lines.push(`${pad}${cl}`);
}
}
@@ -526,16 +495,16 @@ function emitStep(step: WorkflowStepDef, pad: string, currentIndent: string): st
case "ensure": {
const ref = emitRef(step.ref, step.args, step.bareIdentifierArgs);
const capture = step.captureName ? `${step.captureName} = ` : "";
- if (step.recover) {
- const b = step.recover.bindings;
+ if (step.catch) {
+ const b = step.catch.bindings;
const bindStr = `(${b.failure})`;
- if ("single" in step.recover) {
- const recoverLines = emitStep(step.recover.single, pad, "");
+ if ("single" in step.catch) {
+ const recoverLines = emitStep(step.catch.single, pad, "");
const recoverText = recoverLines.map((l) => l.trim()).join("\n");
lines.push(`${ci}${capture}ensure ${ref} catch ${bindStr} ${recoverText}`);
} else {
lines.push(`${ci}${capture}ensure ${ref} catch ${bindStr} {`);
- lines.push(...emitSteps(step.recover.block, pad, ci + pad));
+ lines.push(...emitSteps(step.catch.block, pad, ci + pad));
lines.push(`${ci}}`);
}
} else {
@@ -548,28 +517,28 @@ function emitStep(step: WorkflowStepDef, pad: string, currentIndent: string): st
const ref = emitRef(step.workflow, step.args, step.bareIdentifierArgs);
const capture = step.captureName ? `${step.captureName} = ` : "";
const asyncPrefix = step.async ? "async " : "";
- if (step.recoverLoop) {
- const b = step.recoverLoop.bindings;
+ if (step.recover) {
+ const b = step.recover.bindings;
const bindStr = `(${b.failure})`;
- if ("single" in step.recoverLoop) {
- const recoverLines = emitStep(step.recoverLoop.single, pad, "");
+ if ("single" in step.recover) {
+ const recoverLines = emitStep(step.recover.single, pad, "");
const recoverText = recoverLines.map((l) => l.trim()).join("\n");
lines.push(`${ci}${capture}run ${asyncPrefix}${ref} recover ${bindStr} ${recoverText}`);
} else {
lines.push(`${ci}${capture}run ${asyncPrefix}${ref} recover ${bindStr} {`);
- lines.push(...emitSteps(step.recoverLoop.block, pad, ci + pad));
+ lines.push(...emitSteps(step.recover.block, pad, ci + pad));
lines.push(`${ci}}`);
}
- } else if (step.recover) {
- const b = step.recover.bindings;
+ } else if (step.catch) {
+ const b = step.catch.bindings;
const bindStr = `(${b.failure})`;
- if ("single" in step.recover) {
- const recoverLines = emitStep(step.recover.single, pad, "");
+ if ("single" in step.catch) {
+ const recoverLines = emitStep(step.catch.single, pad, "");
const recoverText = recoverLines.map((l) => l.trim()).join("\n");
lines.push(`${ci}${capture}run ${asyncPrefix}${ref} catch ${bindStr} ${recoverText}`);
} else {
lines.push(`${ci}${capture}run ${asyncPrefix}${ref} catch ${bindStr} {`);
- lines.push(...emitSteps(step.recover.block, pad, ci + pad));
+ lines.push(...emitSteps(step.catch.block, pad, ci + pad));
lines.push(`${ci}}`);
}
} else {
diff --git a/src/parse/const-rhs.ts b/src/parse/const-rhs.ts
index 252a088a..4d528718 100644
--- a/src/parse/const-rhs.ts
+++ b/src/parse/const-rhs.ts
@@ -1,21 +1,15 @@
import type { ConstRhs, RuleRefDef, WorkflowRefDef } from "../types";
-import { fail, isRef, parseCallRef } from "./core";
+import { fail, parseCallRef, rejectTrailingContent } from "./core";
import { parseTripleQuoteBlock, tripleQuoteBodyToRaw } from "./triple-quote";
import { parseAnonymousInlineScript } from "./inline-script";
import { parsePromptStep } from "./prompt";
import { parseMatchExpr } from "./match";
-
-/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
-function rejectTrailingContent(
- filePath: string,
- lineNo: number,
- keyword: string,
- rest: string,
-): void {
- const trimmed = rest.trim();
- if (!trimmed) return;
- fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
-}
+import {
+ bareIdentifierToQuotedString,
+ dottedReturnToQuotedString,
+ isBareDottedIdentifierReturn,
+ isBareIdentifierReturn,
+} from "./workflow-return-dotted";
/**
* Reject P10 disallowed forms: command substitution and bash string ops in const RHS.
@@ -186,5 +180,12 @@ export function parseConstRhs(
);
}
validateConstBashExpr(filePath, head, lineNo, col);
- return { value: { kind: "expr", bashRhs: head }, nextLineIdx: lineIdx };
+ const isBareDotted = isBareDottedIdentifierReturn(head);
+ const isBare = !isBareDotted && isBareIdentifierReturn(head);
+ const bashRhs = isBareDotted
+ ? dottedReturnToQuotedString(head)
+ : isBare
+ ? bareIdentifierToQuotedString(head)
+ : head;
+ return { value: { kind: "expr", bashRhs }, nextLineIdx: lineIdx };
}
diff --git a/src/parse/core.ts b/src/parse/core.ts
index 5d82fedd..c131c794 100644
--- a/src/parse/core.ts
+++ b/src/parse/core.ts
@@ -43,6 +43,40 @@ export function colFromRaw(raw: string): number {
return (raw.match(/\S/)?.index ?? 0) + 1;
}
+/**
+ * Parse a single-backtick body `…` from the start of `text`.
+ * Errors if missing closing backtick or if the body spans multiple lines.
+ * Returns the body and the text remaining after the closing backtick.
+ */
+export function parseSingleBacktickBody(
+ text: string,
+ filePath: string,
+ lineNo: number,
+ col: number,
+): { body: string; restAfterClose: string } {
+ const closeIdx = text.indexOf("`", 1);
+ if (closeIdx === -1) {
+ fail(filePath, "unterminated inline script backtick — missing closing `", lineNo, col);
+ }
+ const body = text.slice(1, closeIdx);
+ if (body.includes("\n")) {
+ fail(filePath, "single backtick script body must be one line — use triple backtick for multiline", lineNo, col);
+ }
+ return { body, restAfterClose: text.slice(closeIdx + 1) };
+}
+
+/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
+export function rejectTrailingContent(
+ filePath: string,
+ lineNo: number,
+ keyword: string,
+ rest: string,
+): void {
+ const trimmed = rest.trim();
+ if (!trimmed) return;
+ fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
+}
+
/** Count brace depth change for a line (ignores quotes; used for inline { ... } in rule/workflow bodies). */
export function braceDepthDelta(line: string): number {
let delta = 0;
@@ -56,7 +90,7 @@ export function braceDepthDelta(line: string): number {
/** Jaiph keywords that cannot be used as bare identifier arguments. */
const JAIPH_KEYWORDS = new Set([
"run", "ensure", "prompt", "return", "fail", "log", "logerr",
- "if", "else", "not", "const", "local", "match", "import", "export",
+ "if", "else", "not", "const", "match", "import", "export",
"workflow", "rule", "script", "channel", "config", "catch", "async",
"returns", "send", "true", "false",
]);
diff --git a/src/parse/fence.ts b/src/parse/fence.ts
index 7c205047..bd404ea7 100644
--- a/src/parse/fence.ts
+++ b/src/parse/fence.ts
@@ -1,41 +1,16 @@
import { fail } from "./core";
-/**
- * Closing line is either exactly ``` or ``` returns "{ schema }" (prompt typed capture on same line).
- */
-function parseClosingFenceLine(
- filePath: string,
- lineNo: number,
- trimmed: string,
-): { kind: "bare" } | { kind: "with_returns"; returns: string } {
- if (trimmed === "```") {
- return { kind: "bare" };
- }
- if (!trimmed.startsWith("```")) {
- fail(filePath, "internal: expected closing line to start with ```", lineNo);
- }
- const m = trimmed.match(/^```\s+returns\s+"((?:[^"\\]|\\.)*)"\s*$/);
- if (m) {
- const content = m[1].replace(/\\"/g, '"');
- return { kind: "with_returns", returns: content };
- }
- fail(
- filePath,
- 'closing fence must be exactly ```, or ``` returns "{ ... }" (same line)',
- lineNo,
- );
-}
-
/**
* Parse a fenced block (``` ... ```) starting at fenceLineIdx.
- * Returns the body between fences, optional lang token, optional returns schema when the closing
- * line uses ``` returns "…", and next line index after the closing line.
+ * Returns the body between fences, optional lang token, the trailing text on
+ * the closing fence line (after the closing ```, callers parse this for
+ * (args) / returns "…" / etc.), and the next line index.
*/
export function parseFencedBlock(
filePath: string,
lines: string[],
fenceLineIdx: number,
-): { body: string; lang?: string; nextIdx: number; returns?: string } {
+): { body: string; lang?: string; afterClose: string; nextIdx: number } {
const lineNo = fenceLineIdx + 1;
const openLine = lines[fenceLineIdx].trim();
@@ -58,11 +33,10 @@ export function parseFencedBlock(
for (; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) {
- const closing = parseClosingFenceLine(filePath, i + 1, trimmed);
return {
body: bodyLines.join("\n"),
...(lang ? { lang } : {}),
- ...(closing.kind === "with_returns" ? { returns: closing.returns } : {}),
+ afterClose: trimmed.slice(3),
nextIdx: i + 1,
};
}
diff --git a/src/parse/imports.ts b/src/parse/imports.ts
index f0b56b6e..8392e248 100644
--- a/src/parse/imports.ts
+++ b/src/parse/imports.ts
@@ -1,15 +1,17 @@
import type { ImportDef, ScriptImportDef } from "../types";
import { fail, stripQuotes } from "./core";
-export function parseImportLine(
+function parsePathAlias(
filePath: string,
line: string,
raw: string,
lineNo: number,
-): ImportDef {
- const match = line.match(/^import\s+(.+?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/);
+ pattern: RegExp,
+ expected: string,
+): { path: string; alias: string; loc: { line: number; col: number } } {
+ const match = line.match(pattern);
if (!match) {
- fail(filePath, 'import must match: import "" as ', lineNo);
+ fail(filePath, expected, lineNo);
}
const pathRaw = match[1].trim();
if (pathRaw.startsWith("'")) {
@@ -22,23 +24,34 @@ export function parseImportLine(
};
}
+export function parseImportLine(
+ filePath: string,
+ line: string,
+ raw: string,
+ lineNo: number,
+): ImportDef {
+ return parsePathAlias(
+ filePath,
+ line,
+ raw,
+ lineNo,
+ /^import\s+(.+?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/,
+ 'import must match: import "" as ',
+ );
+}
+
export function parseScriptImportLine(
filePath: string,
line: string,
raw: string,
lineNo: number,
): ScriptImportDef {
- const match = line.match(/^import\s+script\s+(.+?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/);
- if (!match) {
- fail(filePath, 'import script must match: import script "" as ', lineNo);
- }
- const pathRaw = match[1].trim();
- if (pathRaw.startsWith("'")) {
- fail(filePath, 'single-quoted strings are not supported; use double quotes ("...") instead', lineNo);
- }
- return {
- path: stripQuotes(pathRaw),
- alias: match[2],
- loc: { line: lineNo, col: raw.indexOf("import") + 1 },
- };
+ return parsePathAlias(
+ filePath,
+ line,
+ raw,
+ lineNo,
+ /^import\s+script\s+(.+?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/,
+ 'import script must match: import script "" as ',
+ );
}
diff --git a/src/parse/inline-script.ts b/src/parse/inline-script.ts
index a85f07e5..c7aeebd0 100644
--- a/src/parse/inline-script.ts
+++ b/src/parse/inline-script.ts
@@ -1,4 +1,5 @@
-import { fail, parseParenArgs } from "./core";
+import { fail, parseParenArgs, parseSingleBacktickBody } from "./core";
+import { parseFencedBlock } from "./fence";
import { validateScriptBodyNoInterpolation } from "./scripts";
export interface InlineScriptParsed {
@@ -29,79 +30,47 @@ export function parseAnonymousInlineScript(
// Triple backtick (fenced block)
if (t.startsWith("```")) {
- const afterOpening = t.slice(3);
- let lang: string | undefined;
- if (afterOpening.length > 0) {
- if (/\s/.test(afterOpening)) {
- fail(filePath, "invalid opening fence: only a single lang token is allowed after ```", lineNo, col);
- }
- lang = afterOpening;
+ const fenceLines = [...lines];
+ fenceLines[lineIdx] = t;
+ const { body, lang, afterClose, nextIdx } = parseFencedBlock(filePath, fenceLines, lineIdx);
+ const argsResult = parseParenArgs(afterClose);
+ if (!argsResult) {
+ fail(
+ filePath,
+ "anonymous inline script requires argument list after closing fence: ```(args) or ```()",
+ nextIdx,
+ col,
+ );
}
-
- // Collect body lines until closing ```
- const bodyLines: string[] = [];
- let i = lineIdx + 1;
- for (; i < lines.length; i++) {
- const trimmed = lines[i].trim();
- if (trimmed.startsWith("```")) {
- // Closing line — extract (args) after ```
- const afterClose = trimmed.slice(3);
- const argsResult = parseParenArgs(afterClose);
- if (!argsResult) {
- fail(
- filePath,
- "anonymous inline script requires argument list after closing fence: ```(args) or ```()",
- i + 1,
- col,
- );
- }
- if (argsResult.rest.trim()) {
- fail(
- filePath,
- `unexpected content after anonymous inline script: '${argsResult.rest.trim()}'`,
- i + 1,
- col,
- );
- }
-
- const body = bodyLines.join("\n");
-
- // Check for both fence tag and manual shebang
- if (lang && body.trimStart().startsWith("#!")) {
- fail(
- filePath,
- `fence tag "${lang}" already sets the shebang — remove the manual "#!" line`,
- lineNo,
- col,
- );
- }
-
- return {
- body,
- ...(lang ? { lang } : {}),
- args: argsResult.args,
- ...(argsResult.bareIdentifierArgs ? { bareIdentifierArgs: argsResult.bareIdentifierArgs } : {}),
- nextLineIdx: i + 1,
- };
- }
- bodyLines.push(lines[i]);
+ if (argsResult.rest.trim()) {
+ fail(
+ filePath,
+ `unexpected content after anonymous inline script: '${argsResult.rest.trim()}'`,
+ nextIdx,
+ col,
+ );
}
- fail(filePath, "unterminated fenced block: no closing ``` before end of file", lineNo, col);
+ if (lang && body.trimStart().startsWith("#!")) {
+ fail(
+ filePath,
+ `fence tag "${lang}" already sets the shebang — remove the manual "#!" line`,
+ lineNo,
+ col,
+ );
+ }
+ return {
+ body,
+ ...(lang ? { lang } : {}),
+ args: argsResult.args,
+ ...(argsResult.bareIdentifierArgs ? { bareIdentifierArgs: argsResult.bareIdentifierArgs } : {}),
+ nextLineIdx: nextIdx,
+ };
}
// Single backtick (inline, one line)
if (t.startsWith("`")) {
- const closeIdx = t.indexOf("`", 1);
- if (closeIdx === -1) {
- fail(filePath, "unterminated inline script backtick — missing closing `", lineNo, col);
- }
- const body = t.slice(1, closeIdx);
- if (body.includes("\n")) {
- fail(filePath, "single backtick script body must be one line — use triple backtick for multiline", lineNo, col);
- }
-
- const afterClose = t.slice(closeIdx + 1);
- const argsResult = parseParenArgs(afterClose);
+ const { body, restAfterClose } = parseSingleBacktickBody(t, filePath, lineNo, col);
+ const argsResult = parseParenArgs(restAfterClose);
if (!argsResult) {
fail(
filePath,
diff --git a/src/parse/metadata.ts b/src/parse/metadata.ts
index f131cb32..240a230e 100644
--- a/src/parse/metadata.ts
+++ b/src/parse/metadata.ts
@@ -1,13 +1,5 @@
import type { ConfigBodyPart, WorkflowMetadata } from "../types";
import { colFromRaw, fail } from "./core";
-import { findClosingBraceIndex, splitStatementsOnSemicolons } from "./statement-split";
-
-/** Keys that were removed — produce a clear E_PARSE instead of "unknown key". */
-const REJECTED_KEYS: Record = {
- "runtime.workspace": "runtime.workspace is no longer supported; the workspace is mounted automatically",
- "runtime.docker_enabled": "runtime.docker_enabled is no longer supported; set JAIPH_DOCKER_ENABLED or JAIPH_UNSAFE in the environment",
- "runtime.docker_timeout": "runtime.docker_timeout was renamed to runtime.docker_timeout_seconds",
-};
const ALLOWED_KEYS = new Set([
"agent.default_model",
@@ -18,7 +10,6 @@ const ALLOWED_KEYS = new Set([
"agent.claude_flags",
"run.logs_dir",
"run.debug",
- "run.inbox_parallel",
"run.recover_limit",
"runtime.docker_image",
"runtime.docker_network",
@@ -38,7 +29,6 @@ const KEY_TYPES: Record =
"agent.claude_flags": "string",
"run.logs_dir": "string",
"run.debug": "boolean",
- "run.inbox_parallel": "boolean",
"run.recover_limit": "number",
"runtime.docker_image": "string",
"runtime.docker_network": "string",
@@ -144,106 +134,42 @@ function parseArrayValue(
return fail(filePath, "array not closed with ']'", startIdx, 1);
}
+type ConfigValue = string | boolean | number | string[];
+
+const KEY_SETTERS: Record void> = {
+ "agent.default_model": (m, v) => ((m.agent ??= {}).defaultModel = v as string),
+ "agent.command": (m, v) => ((m.agent ??= {}).command = v as string),
+ "agent.trusted_workspace": (m, v) => ((m.agent ??= {}).trustedWorkspace = v as string),
+ "agent.cursor_flags": (m, v) => ((m.agent ??= {}).cursorFlags = v as string),
+ "agent.claude_flags": (m, v) => ((m.agent ??= {}).claudeFlags = v as string),
+ "run.logs_dir": (m, v) => ((m.run ??= {}).logsDir = v as string),
+ "run.debug": (m, v) => ((m.run ??= {}).debug = v as boolean),
+ "run.recover_limit": (m, v) => ((m.run ??= {}).recoverLimit = v as number),
+ "runtime.docker_image": (m, v) => ((m.runtime ??= {}).dockerImage = v as string),
+ "runtime.docker_network": (m, v) => ((m.runtime ??= {}).dockerNetwork = v as string),
+ "runtime.docker_timeout_seconds": (m, v) => ((m.runtime ??= {}).dockerTimeoutSeconds = v as number),
+ "module.name": (m, v) => ((m.module ??= {}).name = v as string),
+ "module.version": (m, v) => ((m.module ??= {}).version = v as string),
+ "module.description": (m, v) => ((m.module ??= {}).description = v as string),
+};
+
function assignConfigKey(
filePath: string,
out: WorkflowMetadata,
key: string,
- value: string | boolean | number | string[],
+ value: ConfigValue,
lineNo: number,
raw: string,
): void {
validateKeyType(filePath, key, value, lineNo, raw);
-
- if (key === "agent.default_model") {
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.defaultModel = value as string;
- } else if (key === "agent.command") {
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.command = value as string;
- } else if (key === "agent.backend") {
- const backend = value === "cursor" || value === "claude" || value === "codex" ? value : undefined;
- if (!backend) {
- fail(
- filePath,
- 'agent.backend must be "cursor", "claude", or "codex"',
- lineNo,
- colFromRaw(raw),
- );
- }
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.backend = backend;
- } else if (key === "agent.trusted_workspace") {
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.trustedWorkspace = value as string;
- } else if (key === "agent.cursor_flags") {
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.cursorFlags = value as string;
- } else if (key === "agent.claude_flags") {
- if (!out.agent) {
- out.agent = {};
- }
- out.agent.claudeFlags = value as string;
- } else if (key === "run.logs_dir") {
- if (!out.run) {
- out.run = {};
- }
- out.run.logsDir = value as string;
- } else if (key === "run.debug") {
- if (!out.run) {
- out.run = {};
- }
- out.run.debug = value as boolean;
- } else if (key === "run.inbox_parallel") {
- if (!out.run) {
- out.run = {};
- }
- out.run.inboxParallel = value as boolean;
- } else if (key === "run.recover_limit") {
- if (!out.run) {
- out.run = {};
- }
- out.run.recoverLimit = value as number;
- } else if (key === "runtime.docker_image") {
- if (!out.runtime) {
- out.runtime = {};
+ if (key === "agent.backend") {
+ if (value !== "cursor" && value !== "claude" && value !== "codex") {
+ fail(filePath, 'agent.backend must be "cursor", "claude", or "codex"', lineNo, colFromRaw(raw));
}
- out.runtime.dockerImage = value as string;
- } else if (key === "runtime.docker_network") {
- if (!out.runtime) {
- out.runtime = {};
- }
- out.runtime.dockerNetwork = value as string;
- } else if (key === "runtime.docker_timeout_seconds") {
- if (!out.runtime) {
- out.runtime = {};
- }
- out.runtime.dockerTimeoutSeconds = value as number;
- } else if (key === "module.name") {
- if (!out.module) {
- out.module = {};
- }
- out.module.name = value as string;
- } else if (key === "module.version") {
- if (!out.module) {
- out.module = {};
- }
- out.module.version = value as string;
- } else if (key === "module.description") {
- if (!out.module) {
- out.module = {};
- }
- out.module.description = value as string;
+ (out.agent ??= {}).backend = value;
+ return;
}
+ KEY_SETTERS[key]?.(out, value);
}
export function parseConfigBlock(
@@ -255,69 +181,6 @@ export function parseConfigBlock(
const rawOpen = lines[startIndex];
const lineOpen = rawOpen.trim();
- if (!lineOpen.startsWith("config") || !lineOpen.includes("{")) {
- return fail(filePath, "expected config block: config {", openLineNo, colFromRaw(rawOpen));
- }
-
- const openBraceIdx = rawOpen.indexOf("{");
- const closeIdx = findClosingBraceIndex(rawOpen, openBraceIdx);
- if (closeIdx !== -1) {
- const tail = rawOpen.slice(closeIdx + 1).trim();
- if (tail !== "") {
- return fail(filePath, "unexpected content after closing '}' of config block", openLineNo, colFromRaw(rawOpen));
- }
- const inner = rawOpen.slice(openBraceIdx + 1, closeIdx);
- const out: WorkflowMetadata = {};
- const bodySequence: ConfigBodyPart[] = [];
- const assignmentLines = splitStatementsOnSemicolons(inner);
- for (const assignRaw of assignmentLines) {
- const line = assignRaw.trim();
- if (!line) {
- continue;
- }
- if (line.startsWith("#")) {
- bodySequence.push({ kind: "comment", text: line });
- continue;
- }
- const eq = line.indexOf("=");
- if (eq === -1) {
- return fail(filePath, `config line must be key = value: ${line}`, openLineNo, colFromRaw(rawOpen));
- }
- const key = line.slice(0, eq).trim();
- const valuePart = line.slice(eq + 1);
-
- if (REJECTED_KEYS[key]) {
- return fail(filePath, REJECTED_KEYS[key], openLineNo, colFromRaw(rawOpen));
- }
- if (!ALLOWED_KEYS.has(key)) {
- return fail(
- filePath,
- `unknown config key: ${key}. Allowed: ${[...ALLOWED_KEYS].join(", ")}`,
- openLineNo,
- colFromRaw(rawOpen),
- );
- }
-
- let value: string | boolean | number | string[];
- const trimmedValue = valuePart.trim();
- if (trimmedValue === "[") {
- return fail(
- filePath,
- "multiline config arrays require a multiline config { … } block (opening 'config {' alone on its own line)",
- openLineNo,
- colFromRaw(rawOpen),
- );
- }
- value = parseMetadataValue(filePath, rawOpen, valuePart, openLineNo);
- assignConfigKey(filePath, out, key, value, openLineNo, rawOpen);
- bodySequence.push({ kind: "assign", key });
- }
- if (bodySequence.length > 0) {
- out.configBodySequence = bodySequence;
- }
- return { metadata: out, nextIndex: startIndex + 1 };
- }
-
if (!/^config\s*\{\s*$/.test(lineOpen)) {
return fail(filePath, "config block must be exactly 'config {' on its own line", openLineNo, colFromRaw(rawOpen));
}
@@ -353,9 +216,6 @@ export function parseConfigBlock(
const key = line.slice(0, eq).trim();
const valuePart = line.slice(eq + 1);
- if (REJECTED_KEYS[key]) {
- return fail(filePath, REJECTED_KEYS[key], lineNo, colFromRaw(raw));
- }
if (!ALLOWED_KEYS.has(key)) {
return fail(
filePath,
diff --git a/src/parse/parse-const-rhs.test.ts b/src/parse/parse-const-rhs.test.ts
index 311325a4..333f42ab 100644
--- a/src/parse/parse-const-rhs.test.ts
+++ b/src/parse/parse-const-rhs.test.ts
@@ -100,6 +100,22 @@ test("parseConstRhs: parses bash expression", () => {
assert.equal(result.nextLineIdx, 0);
});
+test("parseConstRhs: bare identifier is sugar for interpolated string", () => {
+ const result = parseConstRhs("test.jh", ["const x = response"], 0, "response", 1, 1, false, "x");
+ assert.equal(result.value.kind, "expr");
+ if (result.value.kind === "expr") {
+ assert.equal(result.value.bashRhs, '"${response}"');
+ }
+});
+
+test("parseConstRhs: bare dotted identifier is sugar for interpolated string", () => {
+ const result = parseConstRhs("test.jh", ["const x = response.message"], 0, "response.message", 1, 1, false, "x");
+ assert.equal(result.value.kind, "expr");
+ if (result.value.kind === "expr") {
+ assert.equal(result.value.bashRhs, '"${response.message}"');
+ }
+});
+
test("parseConstRhs: parses run capture", () => {
const result = parseConstRhs("test.jh", ["const x = run my_script()"], 0, "run my_script()", 1, 1, false, "x");
assert.equal(result.value.kind, "run_capture");
diff --git a/src/parse/parse-fence.test.ts b/src/parse/parse-fence.test.ts
index e85a66a9..7c34e927 100644
--- a/src/parse/parse-fence.test.ts
+++ b/src/parse/parse-fence.test.ts
@@ -68,28 +68,27 @@ test("fence: error on text after opening backticks that isn't single token", ()
);
});
-test("fence: error on invalid content on closing fence line", () => {
+test("fence: trailing content on closing fence is returned via afterClose", () => {
const lines = ["```", "body", "``` extra"];
- assert.throws(
- () => parseFencedBlock("test.jh", lines, 0),
- /closing fence must be exactly/,
- );
+ const result = parseFencedBlock("test.jh", lines, 0);
+ assert.equal(result.body, "body");
+ assert.equal(result.afterClose, " extra");
});
test("fence: closing line may include returns schema on same line as ```", () => {
const lines = ['```', "body", '``` returns "{ role: string }"'];
const result = parseFencedBlock("test.jh", lines, 0);
assert.equal(result.body, "body");
- assert.equal(result.returns, "{ role: string }");
+ assert.equal(result.afterClose, ' returns "{ role: string }"');
assert.equal(result.nextIdx, 3);
});
-test("fence: same-line returns with lang on opening fence", () => {
- const lines = ["```text", "x", '``` returns "{ n: number }"'];
+test("fence: same-line trailing content with lang on opening fence", () => {
+ const lines = ["```text", "x", '``` (args)'];
const result = parseFencedBlock("test.jh", lines, 0);
assert.equal(result.body, "x");
assert.equal(result.lang, "text");
- assert.equal(result.returns, "{ n: number }");
+ assert.equal(result.afterClose, ' (args)');
});
test("fence: closing fence with surrounding whitespace is accepted", () => {
diff --git a/src/parse/parse-interpreter-tags.test.ts b/src/parse/parse-interpreter-tags.test.ts
index 65939bcb..78327093 100644
--- a/src/parse/parse-interpreter-tags.test.ts
+++ b/src/parse/parse-interpreter-tags.test.ts
@@ -58,17 +58,6 @@ test("fence tag with manual shebang is rejected", () => {
);
});
-// === Rejected: old script:lang syntax ===
-
-test("old script:lang syntax is rejected with actionable error", () => {
- assert.throws(
- () => parsejaiph("script:node transform = ```\nconsole.log('hi');\n```", "test.jh"),
- (err: any) =>
- err.message.includes("E_PARSE") &&
- err.message.includes("script:lang syntax is no longer supported"),
- );
-});
-
// === Rejected: script:tag with parentheses ===
test("script with parentheses is rejected", () => {
diff --git a/src/parse/parse-metadata.test.ts b/src/parse/parse-metadata.test.ts
index 3cfd6495..a83332c9 100644
--- a/src/parse/parse-metadata.test.ts
+++ b/src/parse/parse-metadata.test.ts
@@ -24,30 +24,6 @@ test("parseConfigBlock: parses boolean values", () => {
assert.equal(metadata.run?.debug, true);
});
-test("parseConfigBlock: rejects runtime.docker_enabled with E_PARSE", () => {
- const lines = [
- "config {",
- " runtime.docker_enabled = false",
- "}",
- ];
- assert.throws(
- () => parseConfigBlock("test.jh", lines, 0),
- /runtime\.docker_enabled is no longer supported.*JAIPH_DOCKER_ENABLED.*JAIPH_UNSAFE/,
- );
-});
-
-test("parseConfigBlock: rejects renamed runtime.docker_timeout key with guidance", () => {
- const lines = [
- "config {",
- " runtime.docker_timeout = 300",
- "}",
- ];
- assert.throws(
- () => parseConfigBlock("test.jh", lines, 0),
- /runtime\.docker_timeout was renamed to runtime\.docker_timeout_seconds/,
- );
-});
-
test("parseConfigBlock: parses integer values", () => {
const lines = [
"config {",
@@ -58,30 +34,27 @@ test("parseConfigBlock: parses integer values", () => {
assert.equal(metadata.runtime?.dockerTimeoutSeconds, 300);
});
-test("parseConfigBlock: rejects runtime.workspace with E_PARSE", () => {
+test("parseConfigBlock: fails on unknown config key", () => {
const lines = [
"config {",
- " runtime.workspace = [",
- ' "src/"',
- ' "lib/"',
- " ]",
+ ' agent.unknown_key = "value"',
"}",
];
assert.throws(
() => parseConfigBlock("test.jh", lines, 0),
- /runtime\.workspace is no longer supported/,
+ /unknown config key/,
);
});
-test("parseConfigBlock: fails on unknown config key", () => {
+test("parseConfigBlock: fails on removed run.inbox_parallel key", () => {
const lines = [
"config {",
- ' agent.unknown_key = "value"',
+ " run.inbox_parallel = true",
"}",
];
assert.throws(
() => parseConfigBlock("test.jh", lines, 0),
- /unknown config key/,
+ /unknown config key: run\.inbox_parallel/,
);
});
@@ -201,18 +174,6 @@ test("parseConfigBlock: handles escape sequences in string values", () => {
assert.equal(metadata.agent?.cursorFlags, "flag\nvalue");
});
-test("parseConfigBlock: rejects runtime.workspace even with empty array", () => {
- const lines = [
- "config {",
- " runtime.workspace = []",
- "}",
- ];
- assert.throws(
- () => parseConfigBlock("test.jh", lines, 0),
- /runtime\.workspace is no longer supported/,
- );
-});
-
test("parseConfigBlock: fails on type mismatch (number where string expected)", () => {
const lines = [
"config {",
diff --git a/src/parse/parse-return.test.ts b/src/parse/parse-return.test.ts
index 3840da0f..3478a418 100644
--- a/src/parse/parse-return.test.ts
+++ b/src/parse/parse-return.test.ts
@@ -272,8 +272,8 @@ test("return bare identifier in catch/recover block", () => {
const ensureStep = mod.workflows[0].steps[0];
assert.equal(ensureStep.type, "ensure");
if (ensureStep.type === "ensure") {
- assert.ok(ensureStep.recover);
- const recoverSteps = "block" in ensureStep.recover! ? ensureStep.recover!.block : [ensureStep.recover!.single];
+ assert.ok(ensureStep.catch);
+ const recoverSteps = "block" in ensureStep.catch! ? ensureStep.catch!.block : [ensureStep.catch!.single];
const retStep = recoverSteps[0];
assert.equal(retStep.type, "return");
if (retStep.type === "return") {
@@ -300,8 +300,8 @@ test("return run in ensure recover block", () => {
const ensureStep = mod.workflows[0].steps[0];
assert.equal(ensureStep.type, "ensure");
if (ensureStep.type === "ensure") {
- assert.ok(ensureStep.recover);
- const recoverSteps = "block" in ensureStep.recover! ? ensureStep.recover!.block : [ensureStep.recover!.single];
+ assert.ok(ensureStep.catch);
+ const recoverSteps = "block" in ensureStep.catch! ? ensureStep.catch!.block : [ensureStep.catch!.single];
const retStep = recoverSteps[0];
assert.equal(retStep.type, "return");
if (retStep.type === "return") {
diff --git a/src/parse/parse-run-async.test.ts b/src/parse/parse-run-async.test.ts
index cd9d0d77..7727ae46 100644
--- a/src/parse/parse-run-async.test.ts
+++ b/src/parse/parse-run-async.test.ts
@@ -124,11 +124,11 @@ test("parse: run async with recover block", () => {
if (step.type === "run") {
assert.equal(step.workflow.value, "foo");
assert.equal(step.async, true);
- assert.ok(step.recoverLoop);
- if (step.recoverLoop && "block" in step.recoverLoop) {
- assert.equal(step.recoverLoop.bindings.failure, "err");
- assert.equal(step.recoverLoop.block.length, 1);
- assert.equal(step.recoverLoop.block[0].type, "log");
+ assert.ok(step.recover);
+ if (step.recover && "block" in step.recover) {
+ assert.equal(step.recover.bindings.failure, "err");
+ assert.equal(step.recover.block.length, 1);
+ assert.equal(step.recover.block[0].type, "log");
}
}
});
@@ -147,9 +147,9 @@ test("parse: run async with multi-line recover block", () => {
assert.equal(step.type, "run");
if (step.type === "run") {
assert.equal(step.async, true);
- assert.ok(step.recoverLoop);
- if (step.recoverLoop && "block" in step.recoverLoop) {
- assert.equal(step.recoverLoop.block.length, 2);
+ assert.ok(step.recover);
+ if (step.recover && "block" in step.recover) {
+ assert.equal(step.recover.block.length, 2);
}
}
});
@@ -166,9 +166,9 @@ test("parse: run async with catch block", () => {
if (step.type === "run") {
assert.equal(step.workflow.value, "bar");
assert.equal(step.async, true);
- assert.ok(step.recover);
- if (step.recover && "block" in step.recover) {
- assert.equal(step.recover.bindings.failure, "e");
+ assert.ok(step.catch);
+ if (step.catch && "block" in step.catch) {
+ assert.equal(step.catch.bindings.failure, "e");
}
}
});
diff --git a/src/parse/parse-steps.test.ts b/src/parse/parse-steps.test.ts
index 895728f7..c4a20985 100644
--- a/src/parse/parse-steps.test.ts
+++ b/src/parse/parse-steps.test.ts
@@ -11,7 +11,7 @@ test("parseEnsureStep: parses basic ensure call", () => {
assert.equal(step.type, "ensure");
if (step.type === "ensure") {
assert.equal(step.ref.value, "my_rule");
- assert.equal(step.recover, undefined);
+ assert.equal(step.catch, undefined);
}
assert.equal(nextIdx, 0);
});
@@ -55,10 +55,10 @@ test("parseEnsureStep: parses ensure with single catch statement", () => {
const lines = [' ensure my_rule() catch (failure) log "failed"'];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], 'my_rule() catch (failure) log "failed"');
if (step.type === "ensure") {
- assert.ok(step.recover);
- assert.equal(step.recover.bindings.failure, "failure");
- if ("single" in step.recover) {
- assert.equal(step.recover.single.type, "log");
+ assert.ok(step.catch);
+ assert.equal(step.catch.bindings.failure, "failure");
+ if ("single" in step.catch) {
+ assert.equal(step.catch.single.type, "log");
}
}
});
@@ -67,10 +67,10 @@ test("parseEnsureStep: parses ensure with catch run statement", () => {
const lines = [" ensure my_rule() catch (err) run fallback()"];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], "my_rule() catch (err) run fallback()");
if (step.type === "ensure") {
- assert.ok(step.recover);
- assert.equal(step.recover.bindings.failure, "err");
- if ("single" in step.recover) {
- assert.equal(step.recover.single.type, "run");
+ assert.ok(step.catch);
+ assert.equal(step.catch.bindings.failure, "err");
+ if ("single" in step.catch) {
+ assert.equal(step.catch.single.type, "run");
}
}
});
@@ -87,9 +87,9 @@ test("parseEnsureStep: parses ensure with catch fail statement", () => {
const lines = [' ensure my_rule() catch (failure) fail "reason"'];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], 'my_rule() catch (failure) fail "reason"');
if (step.type === "ensure") {
- assert.ok(step.recover);
- if ("single" in step.recover) {
- assert.equal(step.recover.single.type, "fail");
+ assert.ok(step.catch);
+ if ("single" in step.catch) {
+ assert.equal(step.catch.single.type, "fail");
}
}
});
@@ -100,11 +100,11 @@ test("parseEnsureStep: parses ensure with inline catch block", () => {
const lines = [' ensure my_rule() catch (failure) { log "a"; log "b" }'];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], 'my_rule() catch (failure) { log "a"; log "b" }');
if (step.type === "ensure") {
- assert.ok(step.recover);
- if ("block" in step.recover) {
- assert.equal(step.recover.block.length, 2);
- assert.equal(step.recover.block[0].type, "log");
- assert.equal(step.recover.block[1].type, "log");
+ assert.ok(step.catch);
+ if ("block" in step.catch) {
+ assert.equal(step.catch.block.length, 2);
+ assert.equal(step.catch.block[0].type, "log");
+ assert.equal(step.catch.block[1].type, "log");
}
}
});
@@ -120,11 +120,11 @@ test("parseEnsureStep: parses ensure with multiline catch block", () => {
];
const { step, nextIdx } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], "my_rule() catch (failure) {");
if (step.type === "ensure") {
- assert.ok(step.recover);
- if ("block" in step.recover) {
- assert.equal(step.recover.block.length, 2);
- assert.equal(step.recover.block[0].type, "log");
- assert.equal(step.recover.block[1].type, "run");
+ assert.ok(step.catch);
+ if ("block" in step.catch) {
+ assert.equal(step.catch.block.length, 2);
+ assert.equal(step.catch.block[0].type, "log");
+ assert.equal(step.catch.block[1].type, "run");
}
}
assert.equal(nextIdx, 3);
@@ -142,16 +142,16 @@ test("parseEnsureStep: multiline catch block with triple-quoted prompt", () => {
];
const { step, nextIdx } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], "gate() catch (err) {");
assert.equal(step.type, "ensure");
- if (step.type === "ensure" && step.recover && "block" in step.recover) {
- assert.equal(step.recover.block.length, 3);
- assert.equal(step.recover.block[0].type, "run");
- const p = step.recover.block[1];
+ if (step.type === "ensure" && step.catch && "block" in step.catch) {
+ assert.equal(step.catch.block.length, 3);
+ assert.equal(step.catch.block[0].type, "run");
+ const p = step.catch.block[1];
assert.equal(p.type, "prompt");
if (p.type === "prompt") {
assert.equal(p.bodyKind, "triple_quoted");
assert.ok(p.raw.includes("fix CI"));
}
- assert.equal(step.recover.block[2].type, "run");
+ assert.equal(step.catch.block[2].type, "run");
}
assert.equal(nextIdx, 6);
});
@@ -165,10 +165,10 @@ test("parseEnsureStep: catch block lines starting with # are comments not shell"
];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], "gate() catch (err) {");
assert.equal(step.type, "ensure");
- if (step.type === "ensure" && step.recover && "block" in step.recover) {
- assert.equal(step.recover.block.length, 2);
- assert.equal(step.recover.block[0].type, "comment");
- assert.equal(step.recover.block[1].type, "run");
+ if (step.type === "ensure" && step.catch && "block" in step.catch) {
+ assert.equal(step.catch.block.length, 2);
+ assert.equal(step.catch.block[0].type, "comment");
+ assert.equal(step.catch.block[1].type, "run");
}
});
@@ -236,9 +236,9 @@ test("parseEnsureStep: catch with shell command", () => {
const lines = [" ensure my_rule() catch (failure) echo fallback"];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], "my_rule() catch (failure) echo fallback");
if (step.type === "ensure") {
- assert.ok(step.recover);
- if ("single" in step.recover) {
- assert.equal(step.recover.single.type, "shell");
+ assert.ok(step.catch);
+ if ("single" in step.catch) {
+ assert.equal(step.catch.single.type, "shell");
}
}
});
@@ -247,9 +247,9 @@ test("parseEnsureStep: catch with logerr statement", () => {
const lines = [' ensure my_rule() catch (failure) logerr "error msg"'];
const { step } = parseEnsureStep("test.jh", lines, 0, 1, lines[0], 'my_rule() catch (failure) logerr "error msg"');
if (step.type === "ensure") {
- assert.ok(step.recover);
- if ("single" in step.recover) {
- assert.equal(step.recover.single.type, "logerr");
+ assert.ok(step.catch);
+ if ("single" in step.catch) {
+ assert.equal(step.catch.single.type, "logerr");
}
}
});
@@ -274,9 +274,9 @@ test("parsejaiph: workflow with ensure catch and multiline triple-quoted prompt"
assert.ok(w);
const ensureStep = w!.steps[0];
assert.equal(ensureStep.type, "ensure");
- if (ensureStep.type === "ensure" && ensureStep.recover && "block" in ensureStep.recover) {
- assert.equal(ensureStep.recover.block.length, 1);
- const p = ensureStep.recover.block[0];
+ if (ensureStep.type === "ensure" && ensureStep.catch && "block" in ensureStep.catch) {
+ assert.equal(ensureStep.catch.block.length, 1);
+ const p = ensureStep.catch.block[0];
assert.equal(p.type, "prompt");
if (p.type === "prompt") {
assert.equal(p.bodyKind, "triple_quoted");
@@ -301,10 +301,10 @@ test("parseRunRecoverStep: parses run with single recover statement", () => {
assert.equal(step.type, "run");
if (step.type === "run") {
assert.equal(step.workflow.value, "my_workflow");
- assert.ok(step.recoverLoop);
- assert.equal(step.recoverLoop!.bindings.failure, "err");
- if ("single" in step.recoverLoop!) {
- assert.equal(step.recoverLoop!.single.type, "log");
+ assert.ok(step.recover);
+ assert.equal(step.recover!.bindings.failure, "err");
+ if ("single" in step.recover!) {
+ assert.equal(step.recover!.single.type, "log");
}
}
});
@@ -314,10 +314,10 @@ test("parseRunRecoverStep: parses run with inline recover block", () => {
const result = parseRunRecoverStep("test.jh", lines, 0, 1, lines[0], 'fix() recover(e) { log "a"; run patch() }');
assert.ok(result);
const step = result!.step;
- if (step.type === "run" && step.recoverLoop && "block" in step.recoverLoop) {
- assert.equal(step.recoverLoop.block.length, 2);
- assert.equal(step.recoverLoop.block[0].type, "log");
- assert.equal(step.recoverLoop.block[1].type, "run");
+ if (step.type === "run" && step.recover && "block" in step.recover) {
+ assert.equal(step.recover.block.length, 2);
+ assert.equal(step.recover.block[0].type, "log");
+ assert.equal(step.recover.block[1].type, "run");
}
});
@@ -331,10 +331,10 @@ test("parseRunRecoverStep: parses run with multiline recover block", () => {
const result = parseRunRecoverStep("test.jh", lines, 0, 1, lines[0], "deploy() recover(err) {");
assert.ok(result);
const step = result!.step;
- if (step.type === "run" && step.recoverLoop && "block" in step.recoverLoop) {
- assert.equal(step.recoverLoop.block.length, 2);
- assert.equal(step.recoverLoop.block[0].type, "log");
- assert.equal(step.recoverLoop.block[1].type, "run");
+ if (step.type === "run" && step.recover && "block" in step.recover) {
+ assert.equal(step.recover.block.length, 2);
+ assert.equal(step.recover.block[0].type, "log");
+ assert.equal(step.recover.block[1].type, "run");
}
assert.equal(result!.nextIdx, 3);
});
@@ -395,7 +395,7 @@ test("parsejaiph: workflow with run recover block", () => {
const runStep = w!.steps[0];
assert.equal(runStep.type, "run");
if (runStep.type === "run") {
- assert.ok(runStep.recoverLoop);
- assert.equal(runStep.recover, undefined);
+ assert.ok(runStep.recover);
+ assert.equal(runStep.catch, undefined);
}
});
diff --git a/src/parse/rules.ts b/src/parse/rules.ts
index b6fb52ce..81466f77 100644
--- a/src/parse/rules.ts
+++ b/src/parse/rules.ts
@@ -1,12 +1,6 @@
import type { RuleDef } from "../types";
import { braceDepthDelta, colFromRaw, fail, parseParamList, stripQuotes } from "./core";
import { parseBlockStatement } from "./workflow-brace";
-import {
- expandBlockLineStatements,
- findClosingBraceIndex,
- shouldApplySemicolonStatementSplit,
- shouldSkipSemicolonSplitForLine,
-} from "./statement-split";
export function parseRuleBlock(
filePath: string,
@@ -54,51 +48,9 @@ export function parseRuleBlock(
if (line[braceIdx] !== "{") {
fail(filePath, "expected '{' after rule header", lineNo);
}
- const closeIdx = findClosingBraceIndex(line, braceIdx);
- const isInlineBody = closeIdx !== -1 && line.slice(closeIdx + 1).trim() === "";
-
- if (isInlineBody) {
- const bodyInner = line.slice(braceIdx + 1, closeIdx);
- const bodyLines = bodyInner.split(/\n/).map((l) => l.trim()).filter(Boolean);
- const chunks: string[] = [];
- for (const bl of bodyLines) {
- if (shouldSkipSemicolonSplitForLine(bl)) {
- chunks.push(bl);
- continue;
- }
- const ex = expandBlockLineStatements(bl);
- if (shouldApplySemicolonStatementSplit(ex)) {
- chunks.push(...ex);
- } else {
- chunks.push(bl);
- }
- }
- for (const chunk of chunks) {
- const t = chunk.trim();
- if (!t) continue;
- if (t.startsWith("#")) {
- rule.steps.push({
- type: "comment",
- text: t,
- loc: { line: lineNo, col: 1 },
- });
- continue;
- }
- const st = parseBlockStatement(filePath, [t], 0, { forRule: true });
- rule.steps.push(st.step);
- }
- return { rule, nextIndex: startIndex + 1, exported: isExported };
- }
-
- if (closeIdx === -1) {
- const afterBrace = line.slice(braceIdx + 1).trim();
- if (afterBrace !== "") {
- fail(
- filePath,
- "expected newline after '{' or a complete inline rule body ending with '}' on the same line",
- lineNo,
- );
- }
+ const afterBrace = line.slice(braceIdx + 1).trim();
+ if (afterBrace !== "") {
+ fail(filePath, "expected newline after '{'", lineNo);
}
let i = startIndex + 1;
@@ -181,27 +133,6 @@ export function parseRuleBlock(
}
continue;
}
- if (!shouldSkipSemicolonSplitForLine(innerRaw)) {
- const expanded = expandBlockLineStatements(innerRaw);
- if (shouldApplySemicolonStatementSplit(expanded) && expanded.length > 1) {
- flushCommand();
- for (const chunk of expanded) {
- const t = chunk.trim();
- if (!t) continue;
- if (t.startsWith("#")) {
- rule.steps.push({
- type: "comment",
- text: t,
- loc: { line: innerNo, col: 1 },
- });
- continue;
- }
- const st = parseBlockStatement(filePath, [t], 0, { forRule: true });
- rule.steps.push(st.step);
- }
- continue;
- }
- }
const st = parseBlockStatement(filePath, lines, i, { forRule: true });
if (st.step.type !== "shell") {
flushCommand();
diff --git a/src/parse/scripts.ts b/src/parse/scripts.ts
index 737bd9df..2ea92056 100644
--- a/src/parse/scripts.ts
+++ b/src/parse/scripts.ts
@@ -1,5 +1,5 @@
import type { ScriptDef } from "../types";
-import { fail } from "./core";
+import { fail, parseSingleBacktickBody } from "./core";
import { parseFencedBlock } from "./fence";
/**
@@ -47,15 +47,6 @@ export function parseScriptBlock(
const raw = lines[startIndex];
const line = raw.trim();
- // Reject old script:lang syntax
- if (/^script:/.test(line)) {
- fail(
- filePath,
- "script:lang syntax is no longer supported; use a fenced block with a lang tag: script name = ```lang",
- lineNo,
- );
- }
-
// Match: [export] script name = ...
const match = line.match(/^(export\s+)?script\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) {
@@ -94,10 +85,10 @@ export function parseScriptBlock(
if (rhs.startsWith("```")) {
const fenceLines = [...lines];
fenceLines[startIndex] = rhs;
- const { body, lang, nextIdx, returns } = parseFencedBlock(filePath, fenceLines, startIndex);
+ const { body, lang, nextIdx, afterClose } = parseFencedBlock(filePath, fenceLines, startIndex);
- if (returns) {
- fail(filePath, 'script definitions do not support "returns" on the closing fence', lineNo);
+ if (afterClose.trim()) {
+ fail(filePath, `unexpected content after closing fence: '${afterClose.trim()}'`, lineNo);
}
// Check for both fence tag and manual shebang
@@ -125,15 +116,8 @@ export function parseScriptBlock(
// Case 2: Single backtick — inline one-line script body
if (rhs.startsWith("`")) {
- const closeIdx = rhs.indexOf("`", 1);
- if (closeIdx === -1) {
- fail(filePath, "unterminated inline script backtick — missing closing `", lineNo);
- }
- const body = rhs.slice(1, closeIdx);
- if (body.includes("\n")) {
- fail(filePath, "single backtick script body must be one line — use triple backtick for multiline", lineNo);
- }
- const trailing = rhs.slice(closeIdx + 1).trim();
+ const { body, restAfterClose } = parseSingleBacktickBody(rhs, filePath, lineNo, 1);
+ const trailing = restAfterClose.trim();
if (trailing) {
fail(filePath, `unexpected content after script body backtick: '${trailing}'`, lineNo);
}
diff --git a/src/parse/send-rhs.ts b/src/parse/send-rhs.ts
index 4dcb4ee9..77f4e929 100644
--- a/src/parse/send-rhs.ts
+++ b/src/parse/send-rhs.ts
@@ -1,19 +1,7 @@
import type { SendRhsDef, WorkflowRefDef } from "../types";
-import { fail, hasUnescapedClosingQuote, indexOfClosingDoubleQuote, isRef, parseCallRef } from "./core";
+import { fail, hasUnescapedClosingQuote, indexOfClosingDoubleQuote, isRef, parseCallRef, rejectTrailingContent } from "./core";
import { parseTripleQuoteBlock, tripleQuoteBodyToRaw } from "./triple-quote";
-/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
-function rejectTrailingContent(
- filePath: string,
- lineNo: number,
- keyword: string,
- rest: string,
-): void {
- const trimmed = rest.trim();
- if (!trimmed) return;
- fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
-}
-
const SEND_RHS_HINT =
'send right-hand side must be a quoted string ("..."), a variable ($name or ${...}), or "run [ [args]" — not raw shell; use a script or use const';
diff --git a/src/parse/steps.ts b/src/parse/steps.ts
index 6db91f01..4a6cf130 100644
--- a/src/parse/steps.ts
+++ b/src/parse/steps.ts
@@ -1,20 +1,8 @@
import type { WorkflowStepDef } from "../types";
import { parseConstRhs } from "./const-rhs";
-import { fail, indexOfClosingDoubleQuote, isRef, parseCallRef, parseLogMessageRhs } from "./core";
+import { fail, indexOfClosingDoubleQuote, isRef, parseCallRef, parseLogMessageRhs, rejectTrailingContent } from "./core";
import { parseAnonymousInlineScript } from "./inline-script";
import { isBareIdentifierReturn, bareIdentifierToQuotedString, isBareDottedIdentifierReturn, dottedReturnToQuotedString } from "./workflow-return-dotted";
-
-/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
-function rejectTrailingContent(
- filePath: string,
- lineNo: number,
- keyword: string,
- rest: string,
-): void {
- const trimmed = rest.trim();
- if (!trimmed) return;
- fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
-}
import { parsePromptStep } from "./prompt";
/**
@@ -248,7 +236,7 @@ function parseCatchStatement(
workflow: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recoverLoop: { block: blockSteps, bindings },
+ recover: { block: blockSteps, bindings },
};
}
if (!after.startsWith("{") && after) {
@@ -258,7 +246,7 @@ function parseCatchStatement(
workflow: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recoverLoop: { single: singleStep, bindings },
+ recover: { single: singleStep, bindings },
};
}
}
@@ -288,7 +276,7 @@ function parseCatchStatement(
workflow: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recover: { block: blockSteps, bindings },
+ catch: { block: blockSteps, bindings },
};
}
if (!after.startsWith("{") && after) {
@@ -298,7 +286,7 @@ function parseCatchStatement(
workflow: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recover: { single: singleStep, bindings },
+ catch: { single: singleStep, bindings },
};
}
}
@@ -340,7 +328,7 @@ function parseCatchStatement(
ref: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recover: { block: blockSteps, bindings },
+ catch: { block: blockSteps, bindings },
};
}
if (!after.startsWith("{") && after) {
@@ -350,7 +338,7 @@ function parseCatchStatement(
ref: { value: callPart.ref, loc: { line: lineNo, col } },
args: callPart.args,
...(callPart.bareIdentifierArgs ? { bareIdentifierArgs: callPart.bareIdentifierArgs } : {}),
- recover: { single: singleStep, bindings },
+ catch: { single: singleStep, bindings },
};
}
}
@@ -512,7 +500,7 @@ export function parseEnsureStep(
fail(filePath, "catch block must contain at least one statement", innerNo, catchCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, 1, s));
- return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
+ return { step: { ...base, catch: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
}
if (afterBindings.startsWith("{")) {
@@ -526,7 +514,7 @@ export function parseEnsureStep(
fail(filePath, "catch block must contain at least one statement", innerNo, catchCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, catchCol, s));
- return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: idx };
+ return { step: { ...base, catch: { block: blockSteps, bindings } }, nextIdx: idx };
}
if (!afterBindings) {
@@ -534,7 +522,7 @@ export function parseEnsureStep(
}
const singleStep = parseCatchStatement(filePath, innerNo, catchCol, afterBindings);
- return { step: { ...base, recover: { single: singleStep, bindings } }, nextIdx: idx };
+ return { step: { ...base, catch: { single: singleStep, bindings } }, nextIdx: idx };
}
/**
@@ -628,7 +616,7 @@ export function parseRunRecoverStep(
fail(filePath, "recover block must contain at least one statement", innerNo, recoverCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, 1, s));
- return { step: { ...base, recoverLoop: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
+ return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
}
if (afterBindings.startsWith("{")) {
@@ -642,7 +630,7 @@ export function parseRunRecoverStep(
fail(filePath, "recover block must contain at least one statement", innerNo, recoverCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, recoverCol, s));
- return { step: { ...base, recoverLoop: { block: blockSteps, bindings } }, nextIdx: idx };
+ return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: idx };
}
if (!afterBindings) {
@@ -650,7 +638,7 @@ export function parseRunRecoverStep(
}
const singleStep = parseCatchStatement(filePath, innerNo, recoverCol, afterBindings);
- return { step: { ...base, recoverLoop: { single: singleStep, bindings } }, nextIdx: idx };
+ return { step: { ...base, recover: { single: singleStep, bindings } }, nextIdx: idx };
}
/**
@@ -743,7 +731,7 @@ export function parseRunCatchStep(
fail(filePath, "catch block must contain at least one statement", innerNo, catchCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, 1, s));
- return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
+ return { step: { ...base, catch: { block: blockSteps, bindings } }, nextIdx: closeLineIdx };
}
if (afterBindings.startsWith("{")) {
@@ -757,7 +745,7 @@ export function parseRunCatchStep(
fail(filePath, "catch block must contain at least one statement", innerNo, catchCol);
}
const blockSteps = statements.map((s) => parseCatchStatement(filePath, innerNo, catchCol, s));
- return { step: { ...base, recover: { block: blockSteps, bindings } }, nextIdx: idx };
+ return { step: { ...base, catch: { block: blockSteps, bindings } }, nextIdx: idx };
}
if (!afterBindings) {
@@ -765,5 +753,5 @@ export function parseRunCatchStep(
}
const singleStep = parseCatchStatement(filePath, innerNo, catchCol, afterBindings);
- return { step: { ...base, recover: { single: singleStep, bindings } }, nextIdx: idx };
+ return { step: { ...base, catch: { single: singleStep, bindings } }, nextIdx: idx };
}
diff --git a/src/parse/triple-quote.ts b/src/parse/triple-quote.ts
index 74865fa6..4856acbf 100644
--- a/src/parse/triple-quote.ts
+++ b/src/parse/triple-quote.ts
@@ -57,3 +57,21 @@ function joinTripleQuoteBody(bodyLines: string[]): string {
export function tripleQuoteBodyToRaw(body: string): string {
return `"${body.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
+
+/**
+ * Helper for step parsers: when a step argument starts with `"""`, splice it back
+ * onto the source line and parse the triple-quoted block. Errors if any content
+ * trails the closing `"""`. Returns the message body and the next source index.
+ */
+export function consumeTripleQuotedArg(
+ filePath: string,
+ lines: string[],
+ idx: number,
+ arg: string,
+): { body: string; nextIdx: number } {
+ const tqLines = [...lines];
+ tqLines[idx] = arg;
+ const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
+ if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
+ return { body, nextIdx };
+}
diff --git a/src/parse/workflow-brace.ts b/src/parse/workflow-brace.ts
index bd4099df..cb12675f 100644
--- a/src/parse/workflow-brace.ts
+++ b/src/parse/workflow-brace.ts
@@ -1,4 +1,4 @@
-import type { WorkflowStepDef } from "../types";
+import type { WorkflowMetadata, WorkflowStepDef } from "../types";
import {
colFromRaw,
fail,
@@ -7,35 +7,29 @@ import {
matchSendOperator,
parseCallRef,
parseLogMessageRhs,
+ rejectTrailingContent,
} from "./core";
-import { parseTripleQuoteBlock, tripleQuoteBodyToRaw } from "./triple-quote";
+import { consumeTripleQuotedArg, tripleQuoteBodyToRaw } from "./triple-quote";
import { parseConstRhs } from "./const-rhs";
import { parseAnonymousInlineScript } from "./inline-script";
+import { parseConfigBlock } from "./metadata";
import { parseEnsureStep, parseRunCatchStep, parseRunRecoverStep } from "./steps";
import { parsePromptStep } from "./prompt";
import { parseSendRhs } from "./send-rhs";
import { parseMatchExpr } from "./match";
import { dottedReturnToQuotedString, isBareDottedIdentifierReturn, isBareIdentifierReturn, bareIdentifierToQuotedString } from "./workflow-return-dotted";
-import {
- expandBlockLineStatements,
- findClosingBraceIndex,
- shouldApplySemicolonStatementSplit,
- shouldSkipSemicolonSplitForLine,
-} from "./statement-split";
-
-/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
-function rejectTrailingContent(
- filePath: string,
- lineNo: number,
- keyword: string,
- rest: string,
-): void {
- const trimmed = rest.trim();
- if (!trimmed) return;
- fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
-}
-export type BlockParseOpts = { forRule?: boolean };
+export type BlockParseOpts = {
+ forRule?: boolean;
+ /** When true, push `blank_line` steps so the formatter can preserve spacing. */
+ preserveBlankLines?: boolean;
+ /**
+ * When set, allow a `config { … }` block as the first non-comment statement.
+ * The callback receives the parsed metadata and may throw via `fail()` to
+ * reject specific keys (workflows reject `runtime.*` and `module.*`).
+ */
+ onConfigBlock?: (metadata: WorkflowMetadata, lineNo: number) => void;
+};
/** Parse statements until a closing `}` at the current block level. */
export function parseBraceBlockBody(
@@ -47,11 +41,18 @@ export function parseBraceBlockBody(
): { steps: WorkflowStepDef[]; nextIdx: number } {
const steps: WorkflowStepDef[] = [];
let idx = startIdx;
+ let hadNonCommentStep = false;
while (idx < lines.length) {
const innerRaw = lines[idx];
const inner = innerRaw.trim();
const innerNo = idx + 1;
if (inner === "") {
+ if (opts?.preserveBlankLines) {
+ const last = steps[steps.length - 1];
+ if (last && last.type !== "blank_line") {
+ steps.push({ type: "blank_line" });
+ }
+ }
idx += 1;
continue;
}
@@ -67,19 +68,27 @@ export function parseBraceBlockBody(
if (inner === "}") {
return { steps, nextIdx: idx + 1 };
}
- if (!shouldSkipSemicolonSplitForLine(innerRaw)) {
- const expanded = expandBlockLineStatements(innerRaw);
- if (shouldApplySemicolonStatementSplit(expanded) && expanded.length > 1) {
- for (const chunk of expanded) {
- const t = chunk.trim();
- if (!t) continue;
- const one = parseBlockStatement(filePath, [t], 0, opts);
- steps.push(one.step);
- }
- idx += 1;
- continue;
+ if (opts?.onConfigBlock && /^config\s*\{/.test(inner)) {
+ if (hadNonCommentStep) {
+ fail(filePath, "config block inside workflow must appear before any steps", innerNo);
}
+ const { metadata, nextIndex } = parseConfigBlock(filePath, lines, idx);
+ opts.onConfigBlock(metadata, innerNo);
+ idx = nextIndex;
+ continue;
+ }
+ // Reject route declarations at body level: routes belong at the top of the file.
+ const routeMatch = inner.match(
+ /^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s+->\s+(.+)$/,
+ );
+ if (routeMatch) {
+ fail(
+ filePath,
+ `route declarations belong at the top level: channel ${routeMatch[1]} -> ${routeMatch[2].trim()}`,
+ innerNo,
+ );
}
+ hadNonCommentStep = true;
const one = parseBlockStatement(filePath, lines, idx, opts);
steps.push(one.step);
idx = one.nextIdx;
@@ -170,10 +179,7 @@ export function parseBlockStatement(
const arg = inner.slice("fail".length).trimStart();
const failCol = innerRaw.indexOf("fail") + 1;
if (arg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = arg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
+ const { body, nextIdx } = consumeTripleQuotedArg(filePath, lines, idx, arg);
const message = tripleQuoteBodyToRaw(body);
return {
step: { type: "fail", message, tripleQuoted: true, loc: { line: innerNo, col: failCol } },
@@ -215,6 +221,22 @@ export function parseBlockStatement(
if (runBody.startsWith("`")) {
fail(filePath, "run async is not supported with inline scripts", innerNo, innerRaw.indexOf("run") + 1);
}
+ // run async ... recover(name) { ... }
+ const recoverResult = parseRunRecoverStep(filePath, lines, idx, innerNo, innerRaw, runBody);
+ if (recoverResult && recoverResult.step.type === "run") {
+ return {
+ step: { ...recoverResult.step, async: true },
+ nextIdx: recoverResult.nextIdx + 1,
+ };
+ }
+ // run async ... catch(name) { ... }
+ const catchResult = parseRunCatchStep(filePath, lines, idx, innerNo, innerRaw, runBody);
+ if (catchResult && catchResult.step.type === "run") {
+ return {
+ step: { ...catchResult.step, async: true },
+ nextIdx: catchResult.nextIdx + 1,
+ };
+ }
const call = parseCallRef(runBody);
if (!call) {
fail(filePath, "run async must target a valid reference: run async ref() or run async ref(args) — parentheses are required", innerNo);
@@ -348,10 +370,7 @@ export function parseBlockStatement(
fail(filePath, 'bare inline scripts in log are not allowed; use "log run `...`()" to execute a managed inline script', innerNo, logCol);
}
if (logArg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = logArg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
+ const { body, nextIdx } = consumeTripleQuotedArg(filePath, lines, idx, logArg);
return { step: { type: "log", message: body, tripleQuoted: true, loc: { line: innerNo, col: logCol } }, nextIdx };
}
if (logArg.startsWith('"') && !hasUnescapedClosingQuote(logArg, 1)) {
@@ -387,10 +406,7 @@ export function parseBlockStatement(
fail(filePath, 'bare inline scripts in logerr are not allowed; use "logerr run `...`()" to execute a managed inline script', innerNo, logerrCol);
}
if (logerrArg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = logerrArg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
+ const { body, nextIdx } = consumeTripleQuotedArg(filePath, lines, idx, logerrArg);
return { step: { type: "logerr", message: body, tripleQuoted: true, loc: { line: innerNo, col: logerrCol } }, nextIdx };
}
if (logerrArg.startsWith('"') && !hasUnescapedClosingQuote(logerrArg, 1)) {
@@ -417,10 +433,7 @@ export function parseBlockStatement(
const retLoc = { line: innerNo, col: innerRaw.indexOf("return") + 1 };
// return """..."""
if (returnValue.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = returnValue;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
+ const { body, nextIdx } = consumeTripleQuotedArg(filePath, lines, idx, returnValue);
return {
step: { type: "return", value: tripleQuoteBodyToRaw(body), tripleQuoted: true, loc: retLoc },
nextIdx,
@@ -502,12 +515,19 @@ export function parseBlockStatement(
if (returnValue.startsWith("'")) {
fail(filePath, 'single-quoted strings are not supported; use double quotes ("...") instead', innerNo, retLoc.col);
}
+ if (/^[0-9]+$/.test(returnValue) || returnValue === "$?") {
+ fail(
+ filePath,
+ 'bash exit codes are only valid in scripts; use return "..." for a workflow value',
+ innerNo,
+ retLoc.col,
+ );
+ }
if (
- !(/^[0-9]+$/.test(returnValue) || returnValue === "$?") &&
- (returnValue.startsWith('"') ||
- returnValue.startsWith("$") ||
- isBareDottedIdentifierReturn(returnValue) ||
- isBareIdentifierReturn(returnValue))
+ returnValue.startsWith('"') ||
+ returnValue.startsWith("$") ||
+ isBareDottedIdentifierReturn(returnValue) ||
+ isBareIdentifierReturn(returnValue)
) {
// Reject multiline "..."
if (returnValue.startsWith('"') && !hasUnescapedClosingQuote(returnValue, 1)) {
diff --git a/src/parse/workflow-return-dotted.ts b/src/parse/workflow-return-dotted.ts
index 723d1e62..e3f18cf2 100644
--- a/src/parse/workflow-return-dotted.ts
+++ b/src/parse/workflow-return-dotted.ts
@@ -1,30 +1,22 @@
/**
- * Bare `base.field` in `return base.field` is sugar for `return "${base.field}"`
+ * Bare `name` or `name.field` in `return ]` is sugar for `return "${expr}"`
* (same interpolation as in double-quoted strings).
*/
-const BARE_DOTTED_RETURN_RE = /^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/;
+const BARE_DOTTED_RE = /^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/;
+const BARE_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
export function isBareDottedIdentifierReturn(expr: string): boolean {
- return BARE_DOTTED_RETURN_RE.test(expr.trim());
+ return BARE_DOTTED_RE.test(expr.trim());
}
-export function dottedReturnToQuotedString(expr: string): string {
- const t = expr.trim();
- const inner = "$" + "{" + t + "}";
- return '"' + inner + '"';
+export function isBareIdentifierReturn(expr: string): boolean {
+ return BARE_RE.test(expr.trim());
}
-/**
- * Bare `response` in `return response` is sugar for `return "${response}"`
- * (same interpolation as in double-quoted strings).
- */
-const BARE_IDENTIFIER_RETURN_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
-
-export function isBareIdentifierReturn(expr: string): boolean {
- return BARE_IDENTIFIER_RETURN_RE.test(expr.trim());
+export function dottedReturnToQuotedString(expr: string): string {
+ return `"\${${expr.trim()}}"`;
}
export function bareIdentifierToQuotedString(expr: string): string {
- const t = expr.trim();
- return '"${' + t + '}"';
+ return `"\${${expr.trim()}}"`;
}
diff --git a/src/parse/workflows.ts b/src/parse/workflows.ts
index 187a1e36..3ec9156f 100644
--- a/src/parse/workflows.ts
+++ b/src/parse/workflows.ts
@@ -1,55 +1,6 @@
import type { WorkflowDef } from "../types";
-import {
- colFromRaw,
- fail,
- hasUnescapedClosingQuote,
- indexOfClosingDoubleQuote,
- matchSendOperator,
- parseCallRef,
- parseLogMessageRhs,
- parseParamList,
-} from "./core";
-import { parseTripleQuoteBlock, tripleQuoteBodyToRaw } from "./triple-quote";
-import { parseConstRhs } from "./const-rhs";
-import { parseConfigBlock } from "./metadata";
-import { parsePromptStep } from "./prompt";
-import { parseSendRhs } from "./send-rhs";
-import { parseAnonymousInlineScript } from "./inline-script";
-import { parseEnsureStep, parseRunCatchStep, parseRunRecoverStep } from "./steps";
-import { parseBraceBlockBody, parseBlockStatement } from "./workflow-brace";
-import { dottedReturnToQuotedString, isBareDottedIdentifierReturn, isBareIdentifierReturn, bareIdentifierToQuotedString } from "./workflow-return-dotted";
-import { parseMatchExpr } from "./match";
-import {
- expandBlockLineStatements,
- findClosingBraceIndex,
- shouldApplySemicolonStatementSplit,
- shouldSkipSemicolonSplitForLine,
-} from "./statement-split";
-
-/** Reject non-empty trailing content after a call expression (e.g. shell redirection). */
-function rejectTrailingContent(
- filePath: string,
- lineNo: number,
- keyword: string,
- rest: string,
-): void {
- const trimmed = rest.trim();
- if (!trimmed) return;
- fail(filePath, `unexpected content after ${keyword} call: '${trimmed}'; shell redirection (>, |, &) is not supported — use a script block`, lineNo);
-}
-
-/**
- * Detect Jaiph value-return syntax vs bash exit-code return.
- * Jaiph value-return: return | return "..." | return '...' | return $var
- * (`return` alone is empty string; see bare-return handling before this matcher.)
- * Bash return: return 0 | return 1 | return $?
- */
-function isJaiphValueReturn(expr: string): boolean {
- const arg = expr.trim();
- if (/^[0-9]+$/.test(arg)) return false;
- if (arg === "$?") return false;
- return arg.startsWith('"') || arg.startsWith("'") || arg.startsWith("$");
-}
+import { fail, parseParamList } from "./core";
+import { parseBraceBlockBody } from "./workflow-brace";
export function parseWorkflowBlock(
filePath: string,
@@ -97,637 +48,37 @@ export function parseWorkflowBlock(
if (lineDecl[braceIdx] !== "{") {
fail(filePath, "expected '{' after workflow header", lineNo);
}
- const closeIdx = findClosingBraceIndex(lineDecl, braceIdx);
- const isInlineBody = closeIdx !== -1 && lineDecl.slice(closeIdx + 1).trim() === "";
+ const afterBrace = lineDecl.slice(braceIdx + 1).trim();
+ if (afterBrace !== "") {
+ fail(filePath, "expected newline after '{'", lineNo);
+ }
- if (isInlineBody) {
- const bodyInner = lineDecl.slice(braceIdx + 1, closeIdx);
- const bodyLines = bodyInner.split(/\n/).map((l) => l.trim()).filter(Boolean);
- const chunks: string[] = [];
- for (const bl of bodyLines) {
- if (shouldSkipSemicolonSplitForLine(bl)) {
- chunks.push(bl);
- continue;
- }
- const ex = expandBlockLineStatements(bl);
- if (shouldApplySemicolonStatementSplit(ex)) {
- chunks.push(...ex);
- } else {
- chunks.push(bl);
- }
- }
- let hadNonCommentStepInline = false;
- for (const chunk of chunks) {
- const t = chunk.trim();
- if (!t) continue;
- if (t.startsWith("#")) {
- workflow.steps.push({
- type: "comment",
- text: t,
- loc: { line: lineNo, col: 1 },
- });
- continue;
- }
- if (/^config\s*\{/.test(t)) {
+ const { steps: bodySteps, nextIdx: afterClose } = parseBraceBlockBody(
+ filePath,
+ lines,
+ startIndex + 1,
+ lineNo,
+ {
+ forRule: false,
+ preserveBlankLines: true,
+ onConfigBlock: (metadata, configLineNo) => {
if (workflow.metadata !== undefined) {
- fail(filePath, "duplicate config block inside workflow (only one allowed per workflow)", lineNo);
- }
- if (hadNonCommentStepInline) {
- fail(filePath, "config block inside workflow must appear before any steps", lineNo);
- }
- const { metadata, nextIndex } = parseConfigBlock(filePath, [t], 0);
- if (nextIndex !== 1) {
- fail(filePath, "internal parse error: inline config expected on one line", lineNo);
+ fail(filePath, "duplicate config block inside workflow (only one allowed per workflow)", configLineNo);
}
if (metadata.runtime) {
- fail(filePath, "runtime.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", lineNo);
+ fail(filePath, "runtime.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", configLineNo);
}
if (metadata.module) {
- fail(filePath, "module.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", lineNo);
+ fail(filePath, "module.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", configLineNo);
}
workflow.metadata = metadata;
- continue;
- }
- hadNonCommentStepInline = true;
- const st = parseBlockStatement(filePath, [t], 0, { forRule: false });
- workflow.steps.push(st.step);
- }
- return { workflow, nextIndex: startIndex + 1, exported: isExported };
- }
-
- if (closeIdx === -1) {
- const afterBrace = lineDecl.slice(braceIdx + 1).trim();
- if (afterBrace !== "") {
- fail(
- filePath,
- "expected newline after '{' or a complete inline workflow body ending with '}' on the same line",
- lineNo,
- );
- }
- }
-
- let idx = startIndex + 1;
- /** Track whether a non-comment step has been seen (config must come first). */
- let hadNonCommentStep = false;
-
- for (; idx < lines.length; idx += 1) {
- const innerNo = idx + 1;
- const innerRaw = lines[idx];
- const inner = innerRaw.trim();
- if (!inner) {
- // Preserve a single blank line between steps for the formatter.
- const lastStep = workflow.steps[workflow.steps.length - 1];
- if (lastStep && lastStep.type !== "blank_line") {
- workflow.steps.push({ type: "blank_line" });
- }
- continue;
- }
- if (inner === "}") {
- break;
- }
- if (inner.startsWith("#")) {
- workflow.steps.push({
- type: "comment",
- text: innerRaw.trim(),
- loc: { line: innerNo, col: 1 },
- });
- continue;
- }
- if (/^config\s*\{/.test(inner)) {
- if (workflow.metadata !== undefined) {
- fail(filePath, "duplicate config block inside workflow (only one allowed per workflow)", innerNo);
- }
- if (hadNonCommentStep) {
- fail(filePath, "config block inside workflow must appear before any steps", innerNo);
- }
- const { metadata, nextIndex } = parseConfigBlock(filePath, lines, idx);
- if (metadata.runtime) {
- fail(filePath, "runtime.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", innerNo);
- }
- if (metadata.module) {
- fail(filePath, "module.* keys are not allowed in workflow-level config (only agent.* and run.* keys)", innerNo);
- }
- workflow.metadata = metadata;
- idx = nextIndex - 1;
- continue;
- }
-
- if (!shouldSkipSemicolonSplitForLine(innerRaw)) {
- const expanded = expandBlockLineStatements(innerRaw);
- if (shouldApplySemicolonStatementSplit(expanded) && expanded.length > 1) {
- for (const chunk of expanded) {
- const t = chunk.trim();
- if (!t) continue;
- if (t.startsWith("#")) {
- workflow.steps.push({
- type: "comment",
- text: t,
- loc: { line: innerNo, col: 1 },
- });
- continue;
- }
- if (/^config\s*\{/.test(t)) {
- fail(
- filePath,
- "config must be the first workflow step; it cannot appear after semicolon-separated steps on the same line",
- innerNo,
- );
- }
- hadNonCommentStep = true;
- const st = parseBlockStatement(filePath, [t], 0, { forRule: false });
- workflow.steps.push(st.step);
- }
- continue;
- }
- }
-
- hadNonCommentStep = true;
-
- const constDecl = inner.match(/^const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/s);
- if (constDecl) {
- const name = constDecl[1];
- const rhs = constDecl[2].trim();
- const { value, nextLineIdx } = parseConstRhs(
- filePath, lines, idx, rhs, innerNo, innerRaw.indexOf(rhs) + 1, false, name,
- );
- const nextLine = nextLineIdx > idx ? nextLineIdx + 1 : idx + 1;
- workflow.steps.push({
- type: "const",
- name,
- value,
- loc: { line: innerNo, col: innerRaw.indexOf("const") + 1 },
- });
- idx = nextLine - 1;
- continue;
- }
-
- const failLine = inner.match(/^fail\s+/);
- if (failLine) {
- const arg = inner.slice("fail".length).trimStart();
- const failCol = innerRaw.indexOf("fail") + 1;
- if (arg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = arg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
- workflow.steps.push({
- type: "fail",
- message: tripleQuoteBodyToRaw(body),
- tripleQuoted: true,
- loc: { line: innerNo, col: failCol },
- });
- idx = nextIdx - 1;
- continue;
- }
- if (!arg.startsWith('"')) {
- fail(filePath, 'fail must match: fail "" or fail """..."""', innerNo, failCol);
- }
- if (!hasUnescapedClosingQuote(arg, 1)) {
- fail(filePath, 'multiline strings use triple quotes: fail """..."""', innerNo, failCol);
- }
- const closeIdx = indexOfClosingDoubleQuote(arg, 1);
- if (closeIdx === -1) {
- fail(filePath, "unterminated fail string", innerNo, failCol);
- }
- const message = arg.slice(0, closeIdx + 1);
- workflow.steps.push({
- type: "fail",
- message,
- loc: { line: innerNo, col: failCol },
- });
- continue;
- }
-
- if (inner === "wait") {
- fail(filePath, '"wait" has been removed from the language', innerNo, innerRaw.indexOf("wait") + 1);
- }
-
- const promptAssignMatch = inner.match(
- /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*prompt\s+(.+)$/s,
- );
- if (promptAssignMatch) {
- fail(
- filePath,
- 'use "const name = prompt ..." to capture the prompt result (e.g. const answer = prompt "..." )',
- innerNo,
- innerRaw.indexOf(promptAssignMatch[1]) + 1,
- );
- }
-
- if (inner.startsWith("prompt ")) {
- const promptCol = innerRaw.indexOf("prompt") + 1;
- const promptArg = innerRaw.slice(innerRaw.indexOf("prompt") + "prompt".length).trimStart();
- const result = parsePromptStep(filePath, lines, idx, promptArg, promptCol);
- idx = result.nextLineIdx;
- workflow.steps.push(result.step);
- continue;
- }
-
- const genericAssignMatch = inner.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+=\s*(.+)$/s);
- if (
- genericAssignMatch &&
- !genericAssignMatch[2].trimStart().startsWith("prompt ") &&
- !genericAssignMatch[2].trimStart().startsWith('"') &&
- !genericAssignMatch[2].trimStart().startsWith("'") &&
- !genericAssignMatch[2].trimStart().startsWith("$")
- ) {
- const captureName = genericAssignMatch[1];
- const rest = genericAssignMatch[2].trim();
- if (rest.startsWith("run ") || rest.startsWith("ensure ")) {
- fail(
- filePath,
- `assignment without "const" is no longer supported; use "const ${captureName} = ${rest}"`,
- innerNo,
- innerRaw.indexOf(captureName) + 1,
- );
- }
- }
-
- if (inner.startsWith("ensure ")) {
- const result = parseEnsureStep(
- filePath, lines, idx, innerNo, innerRaw,
- inner.slice("ensure ".length).trim(),
- );
- idx = result.nextIdx;
- workflow.steps.push(result.step);
- continue;
- }
-
- if (inner.startsWith("run async ")) {
- const runBody = inner.slice("run async ".length).trim();
- if (runBody.startsWith("`")) {
- fail(filePath, "run async is not supported with inline scripts", innerNo, innerRaw.indexOf("run") + 1);
- }
- // Check for run async ... recover (loop semantics)
- const recoverResult = parseRunRecoverStep(filePath, lines, idx, innerNo, innerRaw, runBody);
- if (recoverResult) {
- if (recoverResult.step.type === "run") recoverResult.step.async = true;
- workflow.steps.push(recoverResult.step);
- idx = recoverResult.nextIdx;
- continue;
- }
- // Check for run async ... catch
- const catchResult = parseRunCatchStep(filePath, lines, idx, innerNo, innerRaw, runBody);
- if (catchResult) {
- if (catchResult.step.type === "run") catchResult.step.async = true;
- workflow.steps.push(catchResult.step);
- idx = catchResult.nextIdx;
- continue;
- }
- const call = parseCallRef(runBody);
- if (!call) {
- fail(filePath, "run async must target a valid reference: run async ref() or run async ref(args) — parentheses are required", innerNo);
- }
- rejectTrailingContent(filePath, innerNo, "run async", call.rest);
- workflow.steps.push({
- type: "run",
- workflow: {
- value: call.ref,
- loc: { line: innerNo, col: innerRaw.indexOf("run") + 1 },
- },
- args: call.args,
- ...(call.bareIdentifierArgs ? { bareIdentifierArgs: call.bareIdentifierArgs } : {}),
- async: true,
- });
- continue;
- }
-
- if (inner.startsWith("run ")) {
- const runBody = inner.slice("run ".length).trim();
- if (runBody.startsWith("`")) {
- const result = parseAnonymousInlineScript(filePath, lines, idx, runBody, innerNo, innerRaw.indexOf("run") + 1);
- workflow.steps.push({
- type: "run_inline_script",
- body: result.body,
- ...(result.lang ? { lang: result.lang } : {}),
- args: result.args,
- ...(result.bareIdentifierArgs ? { bareIdentifierArgs: result.bareIdentifierArgs } : {}),
- loc: { line: innerNo, col: innerRaw.indexOf("run") + 1 },
- });
- idx = result.nextLineIdx - 1;
- continue;
- }
- if (runBody.startsWith("script(") || runBody.startsWith("script (")) {
- fail(filePath, 'inline script syntax has changed: use run `body`(args) instead of run script(args) "body"', innerNo);
- }
- // Check for run ... recover (loop semantics)
- const recoverResult = parseRunRecoverStep(filePath, lines, idx, innerNo, innerRaw, runBody);
- if (recoverResult) {
- workflow.steps.push(recoverResult.step);
- idx = recoverResult.nextIdx;
- continue;
- }
- // Check for run ... catch
- const catchResult = parseRunCatchStep(filePath, lines, idx, innerNo, innerRaw, runBody);
- if (catchResult) {
- workflow.steps.push(catchResult.step);
- idx = catchResult.nextIdx;
- continue;
- }
- const call = parseCallRef(runBody);
- if (!call) {
- fail(filePath, "run must target a valid reference: run ref() or run ref(args) — parentheses are required", innerNo);
- }
- rejectTrailingContent(filePath, innerNo, "run", call.rest);
- workflow.steps.push({
- type: "run",
- workflow: {
- value: call.ref,
- loc: { line: innerNo, col: innerRaw.indexOf("run") + 1 },
- },
- args: call.args,
- ...(call.bareIdentifierArgs ? { bareIdentifierArgs: call.bareIdentifierArgs } : {}),
- });
- continue;
- }
-
- if (inner.startsWith("log ") || inner === "log") {
- const logArg = inner.slice("log".length).trimStart();
- const logCol = innerRaw.indexOf("log") + 1;
- if (logArg.startsWith("run ") && logArg.slice("run ".length).trimStart().startsWith("`")) {
- const runBody = logArg.slice("run ".length).trim();
- const result = parseAnonymousInlineScript(filePath, lines, idx, runBody, innerNo, logCol);
- workflow.steps.push({
- type: "log",
- message: "",
- loc: { line: innerNo, col: logCol },
- managed: {
- kind: "run_inline_script",
- body: result.body,
- ...(result.lang ? { lang: result.lang } : {}),
- args: result.args,
- ...(result.bareIdentifierArgs ? { bareIdentifierArgs: result.bareIdentifierArgs } : {}),
- },
- });
- idx = result.nextLineIdx - 1;
- continue;
- }
- if (logArg.startsWith("`") || logArg.startsWith("```")) {
- fail(filePath, 'bare inline scripts in log are not allowed; use "log run `...`()" to execute a managed inline script', innerNo, logCol);
- }
- if (logArg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = logArg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
- workflow.steps.push({ type: "log", message: body, tripleQuoted: true, loc: { line: innerNo, col: logCol } });
- idx = nextIdx - 1;
- continue;
- }
- if (logArg.startsWith('"') && !hasUnescapedClosingQuote(logArg, 1)) {
- fail(filePath, 'multiline strings use triple quotes: log """..."""', innerNo, logCol);
- }
- const message = parseLogMessageRhs(filePath, innerNo, logCol, logArg, "log");
- workflow.steps.push({ type: "log", message, loc: { line: innerNo, col: logCol } });
- continue;
- }
-
- if (inner.startsWith("logerr ") || inner === "logerr") {
- const logerrArg = inner.slice("logerr".length).trimStart();
- const logerrCol = innerRaw.indexOf("logerr") + 1;
- if (logerrArg.startsWith("run ") && logerrArg.slice("run ".length).trimStart().startsWith("`")) {
- const runBody = logerrArg.slice("run ".length).trim();
- const result = parseAnonymousInlineScript(filePath, lines, idx, runBody, innerNo, logerrCol);
- workflow.steps.push({
- type: "logerr",
- message: "",
- loc: { line: innerNo, col: logerrCol },
- managed: {
- kind: "run_inline_script",
- body: result.body,
- ...(result.lang ? { lang: result.lang } : {}),
- args: result.args,
- ...(result.bareIdentifierArgs ? { bareIdentifierArgs: result.bareIdentifierArgs } : {}),
- },
- });
- idx = result.nextLineIdx - 1;
- continue;
- }
- if (logerrArg.startsWith("`") || logerrArg.startsWith("```")) {
- fail(filePath, 'bare inline scripts in logerr are not allowed; use "logerr run `...`()" to execute a managed inline script', innerNo, logerrCol);
- }
- if (logerrArg.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = logerrArg;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
- workflow.steps.push({ type: "logerr", message: body, tripleQuoted: true, loc: { line: innerNo, col: logerrCol } });
- idx = nextIdx - 1;
- continue;
- }
- if (logerrArg.startsWith('"') && !hasUnescapedClosingQuote(logerrArg, 1)) {
- fail(filePath, 'multiline strings use triple quotes: logerr """..."""', innerNo, logerrCol);
- }
- const message = parseLogMessageRhs(filePath, innerNo, logerrCol, logerrArg, "logerr");
- workflow.steps.push({ type: "logerr", message, loc: { line: innerNo, col: logerrCol } });
- continue;
- }
-
- /** Bare `return` exits the workflow with an empty string (not a Bash `return` shell step). */
- if (inner.trim() === "return") {
- workflow.steps.push({
- type: "return",
- value: '""',
- loc: { line: innerNo, col: innerRaw.indexOf("return") + 1 },
- });
- continue;
- }
-
- const returnMatch = inner.match(/^return\s+(.+)$/s);
- if (returnMatch) {
- const returnValue = returnMatch[1].trim();
- const retLoc = { line: innerNo, col: innerRaw.indexOf("return") + 1 };
- // return """..."""
- if (returnValue.startsWith('"""')) {
- const tqLines = [...lines];
- tqLines[idx] = returnValue;
- const { body, nextIdx, afterClose } = parseTripleQuoteBlock(filePath, tqLines, idx);
- if (afterClose) fail(filePath, 'unexpected content after closing """', nextIdx);
- workflow.steps.push({ type: "return", value: tripleQuoteBodyToRaw(body), tripleQuoted: true, loc: retLoc });
- idx = nextIdx - 1;
- continue;
- }
- // return match var { ... }
- const returnMatchHead = returnValue.match(/^match\s+(.+?)\s*\{\s*$/);
- if (returnMatchHead) {
- const subject = returnMatchHead[1].trim();
- const { expr, nextIndex } = parseMatchExpr(filePath, lines, idx, subject, retLoc);
- workflow.steps.push({
- type: "return",
- value: `__match__`,
- loc: retLoc,
- managed: { kind: "match", match: expr },
- });
- idx = nextIndex - 1;
- continue;
- }
- if (returnValue.startsWith("run ")) {
- const runBody = returnValue.slice("run ".length).trim();
- if (runBody.startsWith("`")) {
- const result = parseAnonymousInlineScript(filePath, lines, idx, runBody, innerNo, innerRaw.indexOf("run") + 1);
- workflow.steps.push({
- type: "return",
- value: `run inline_script`,
- loc: retLoc,
- managed: {
- kind: "run_inline_script",
- body: result.body,
- ...(result.lang ? { lang: result.lang } : {}),
- args: result.args,
- ...(result.bareIdentifierArgs ? { bareIdentifierArgs: result.bareIdentifierArgs } : {}),
- },
- });
- idx = result.nextLineIdx - 1;
- continue;
- }
- const call = parseCallRef(runBody);
- if (call) {
- rejectTrailingContent(filePath, innerNo, "run", call.rest);
- workflow.steps.push({
- type: "return",
- value: `run ${call.ref}(${call.args ?? ""})`,
- loc: retLoc,
- managed: {
- kind: "run", ref: { value: call.ref, loc: retLoc }, args: call.args,
- ...(call.bareIdentifierArgs ? { bareIdentifierArgs: call.bareIdentifierArgs } : {}),
- },
- });
- continue;
- }
- }
- if (returnValue.startsWith("ensure ")) {
- const call = parseCallRef(returnValue.slice("ensure ".length).trim());
- if (call) {
- rejectTrailingContent(filePath, innerNo, "ensure", call.rest);
- workflow.steps.push({
- type: "return",
- value: `ensure ${call.ref}(${call.args ?? ""})`,
- loc: retLoc,
- managed: {
- kind: "ensure", ref: { value: call.ref, loc: retLoc }, args: call.args,
- ...(call.bareIdentifierArgs ? { bareIdentifierArgs: call.bareIdentifierArgs } : {}),
- },
- });
- continue;
- }
- }
- if (returnValue.startsWith("`") || returnValue.startsWith("```")) {
- fail(filePath, 'bare inline scripts in return are not allowed; use "return run `...`()" to execute a managed inline script', innerNo, retLoc.col);
- }
- if (returnValue.startsWith("'")) {
- fail(filePath, 'single-quoted strings are not supported; use double quotes ("...") instead', innerNo, retLoc.col);
- }
- if (isJaiphValueReturn(returnValue) || isBareDottedIdentifierReturn(returnValue) || isBareIdentifierReturn(returnValue)) {
- // Reject multiline "..."
- if (returnValue.startsWith('"') && !hasUnescapedClosingQuote(returnValue, 1)) {
- fail(filePath, 'multiline strings use triple quotes: return """..."""', innerNo, retLoc.col);
- }
- const isBareDotted = isBareDottedIdentifierReturn(returnValue);
- const isBare = !isBareDotted && isBareIdentifierReturn(returnValue);
- const value = isBareDotted
- ? dottedReturnToQuotedString(returnValue)
- : isBare
- ? bareIdentifierToQuotedString(returnValue)
- : returnValue;
- workflow.steps.push({
- type: "return",
- value,
- loc: retLoc,
- ...(isBareDotted || isBare ? { bareSource: returnValue.trim() } : {}),
- });
- continue;
- }
- }
-
- const ifHead = inner.match(
- /^if\s+([A-Za-z_][A-Za-z0-9_]*)\s+(==|!=|=~|!~)\s+("(?:[^"\\]|\\.)*"|\/(?:[^/\\]|\\.)*\/)\s*\{\s*$/,
- );
- if (ifHead) {
- const subject = ifHead[1];
- const operator = ifHead[2] as "==" | "!=" | "=~" | "!~";
- const rawOperand = ifHead[3];
- const ifLoc = { line: innerNo, col: innerRaw.indexOf("if") + 1 };
-
- let operand: { kind: "string_literal"; value: string } | { kind: "regex"; source: string };
- if (rawOperand.startsWith('"')) {
- operand = { kind: "string_literal", value: rawOperand.slice(1, -1) };
- } else {
- operand = { kind: "regex", source: rawOperand.slice(1, -1) };
- }
-
- if ((operator === "==" || operator === "!=") && operand.kind === "regex") {
- fail(filePath, `operator "${operator}" requires a string operand ("..."), not a regex`, innerNo, ifLoc.col);
- }
- if ((operator === "=~" || operator === "!~") && operand.kind === "string_literal") {
- fail(filePath, `operator "${operator}" requires a regex operand (/pattern/), not a string`, innerNo, ifLoc.col);
- }
-
- const { steps: body, nextIdx } = parseBraceBlockBody(filePath, lines, idx + 1, innerNo);
- workflow.steps.push({ type: "if", subject, operator, operand, body, loc: ifLoc });
- idx = nextIdx - 1;
- continue;
- }
- if (/^if[\s(]/.test(inner)) {
- fail(
- filePath,
- 'invalid if syntax; expected: if { ... } where op is ==, !=, =~, or !~ and operand is "string" or /regex/',
- innerNo,
- innerRaw.indexOf("if") + 1,
- );
- }
-
- // Standalone match statement: match { ... }
- const standaloneMatchHead = inner.match(/^match\s+(.+?)\s*\{\s*$/);
- if (standaloneMatchHead) {
- const subject = standaloneMatchHead[1].trim();
- const matchLoc = { line: innerNo, col: innerRaw.indexOf("match") + 1 };
- const { expr, nextIndex } = parseMatchExpr(filePath, lines, idx, subject, matchLoc);
- workflow.steps.push({ type: "match", expr });
- idx = nextIndex - 1;
- continue;
- }
-
- const routeMatch = inner.match(
- /^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s+->\s+(.+)$/,
- );
- if (routeMatch) {
- const channel = routeMatch[1];
- const targets = routeMatch[2].trim();
- fail(
- filePath,
- `route declarations belong at the top level: channel ${channel} -> ${targets}`,
- innerNo,
- );
- }
-
- const sendMatch = matchSendOperator(inner);
- if (sendMatch) {
- const arrowIdx = inner.indexOf("<-");
- const rhsCol = arrowIdx >= 0 ? arrowIdx + 3 : 1;
- const { rhs, nextIdx: sendNextIdx } = parseSendRhs(filePath, sendMatch.rhsText, innerNo, rhsCol, lines, idx);
- workflow.steps.push({
- type: "send",
- channel: sendMatch.channel,
- rhs,
- loc: { line: innerNo, col: 1 },
- });
- idx = sendNextIdx - 1;
- continue;
- }
-
- workflow.steps.push({
- type: "shell",
- command: inner,
- loc: { line: innerNo, col: colFromRaw(innerRaw) },
- });
- }
-
- if (idx >= lines.length) {
- fail(filePath, `unterminated workflow block: ${workflow.name}`, lineNo);
- }
+ },
+ },
+ );
+ workflow.steps.push(...bodySteps);
// Strip trailing blank_line (whitespace before closing brace).
while (workflow.steps.length > 0 && workflow.steps[workflow.steps.length - 1].type === "blank_line") {
workflow.steps.pop();
}
- return { workflow, nextIndex: idx + 1, exported: isExported };
+ return { workflow, nextIndex: afterClose, exported: isExported };
}
diff --git a/src/parser.ts b/src/parser.ts
index dfb8f893..15696835 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -88,8 +88,10 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
continue;
}
- const isTestFile = filePath.endsWith(".test.jh");
- if (isTestFile && line.startsWith("test ")) {
+ if (line.startsWith("test ")) {
+ if (!filePath.endsWith(".test.jh")) {
+ fail(filePath, "test blocks belong in *.test.jh files; rename the file or remove the test block", lineNo);
+ }
if (!mod.tests) {
mod.tests = [];
}
@@ -106,10 +108,6 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
continue;
}
- if (/^local\s+[A-Za-z_]/.test(line)) {
- fail(filePath, 'unknown top-level keyword "local" — use const NAME = VALUE', lineNo, 1);
- }
-
if (/^const\s+[A-Za-z_]/.test(line)) {
const { envDecl, nextIndex } = parseEnvDecl(filePath, lines, i - 1);
if (pendingTopLevelComments.length > 0) {
@@ -125,7 +123,7 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
continue;
}
- if (line.includes("rule ")) {
+ if (/^(export\s+)?rule\s/.test(line)) {
const { rule, nextIndex, exported } = parseRuleBlock(filePath, lines, i - 1, pendingTopLevelComments);
pendingTopLevelComments = [];
if (exported) {
@@ -137,7 +135,7 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
continue;
}
- if (line.includes("script ") || line.startsWith("script:")) {
+ if (/^(export\s+)?script\s/.test(line)) {
const { scriptDef, nextIndex, exported } = parseScriptBlock(filePath, lines, i - 1, pendingTopLevelComments);
pendingTopLevelComments = [];
if (exported) {
@@ -149,7 +147,7 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
continue;
}
- if (line.includes("workflow ")) {
+ if (/^(export\s+)?workflow\s/.test(line)) {
const { workflow, nextIndex, exported } = parseWorkflowBlock(filePath, lines, i - 1, pendingTopLevelComments);
pendingTopLevelComments = [];
if (exported) {
@@ -168,57 +166,26 @@ export function parsejaiph(source: string, filePath: string): jaiphModule {
mod.trailingTopLevelComments = [...pendingTopLevelComments];
}
- // Unified namespace: rules, workflows, and scripts share a single name space.
+ // Unified namespace: imports, channels, rules, workflows, scripts, and consts all share one name space.
const seen = new Map();
- if (mod.scriptImports) {
- for (const si of mod.scriptImports) {
- const prev = seen.get(si.alias);
- if (prev) {
- fail(filePath, `duplicate name "${si.alias}" — channels, rules, workflows, and scripts share a single namespace (already declared as ${prev})`, si.loc.line, si.loc.col);
- }
- seen.set(si.alias, "script import");
- }
- }
- for (const ch of mod.channels) {
- const prev = seen.get(ch.name);
- if (prev) {
- fail(
- filePath,
- `duplicate name "${ch.name}" — channels, rules, workflows, and scripts share a single namespace (already declared as ${prev})`,
- ch.loc.line,
- ch.loc.col,
- );
- }
- seen.set(ch.name, "channel");
- }
- for (const r of mod.rules) {
- const prev = seen.get(r.name);
- if (prev) {
- fail(filePath, `duplicate name "${r.name}" — rules, workflows, and scripts share a single namespace (already declared as ${prev})`, r.loc.line, r.loc.col);
- }
- seen.set(r.name, "rule");
- }
- for (const sc of mod.scripts) {
- const prev = seen.get(sc.name);
- if (prev) {
- fail(filePath, `duplicate name "${sc.name}" — rules, workflows, and scripts share a single namespace (already declared as ${prev})`, sc.loc.line, sc.loc.col);
- }
- seen.set(sc.name, "script");
- }
- for (const w of mod.workflows) {
- const prev = seen.get(w.name);
- if (prev) {
- fail(filePath, `duplicate name "${w.name}" — rules, workflows, and scripts share a single namespace (already declared as ${prev})`, w.loc.line, w.loc.col);
- }
- seen.set(w.name, "workflow");
- }
- if (mod.envDecls) {
- for (const env of mod.envDecls) {
- const prev = seen.get(env.name);
+ const groups: Array<{ items: Array<{ name: string; loc: { line: number; col: number } }>; kind: string }> = [
+ { items: (mod.scriptImports ?? []).map((si) => ({ name: si.alias, loc: si.loc })), kind: "script import" },
+ { items: mod.channels.map((c) => ({ name: c.name, loc: c.loc })), kind: "channel" },
+ { items: mod.rules.map((r) => ({ name: r.name, loc: r.loc })), kind: "rule" },
+ { items: mod.scripts.map((s) => ({ name: s.name, loc: s.loc })), kind: "script" },
+ { items: mod.workflows.map((w) => ({ name: w.name, loc: w.loc })), kind: "workflow" },
+ { items: (mod.envDecls ?? []).map((e) => ({ name: e.name, loc: e.loc })), kind: "const" },
+ ];
+ for (const { items, kind } of groups) {
+ for (const { name, loc } of items) {
+ const prev = seen.get(name);
if (prev) {
- fail(filePath, `duplicate name "${env.name}" — variable name collides with ${prev} of the same name`, env.loc.line, env.loc.col);
+ const msg = kind === "const"
+ ? `duplicate name "${name}" — variable name collides with ${prev} of the same name`
+ : `duplicate name "${name}" — channels, rules, workflows, and scripts share a single namespace (already declared as ${prev})`;
+ fail(filePath, msg, loc.line, loc.col);
}
- seen.set(env.name, "const");
+ seen.set(name, kind);
}
}
diff --git a/src/runtime/docker.test.ts b/src/runtime/docker.test.ts
index 02b83019..2984de2c 100644
--- a/src/runtime/docker.test.ts
+++ b/src/runtime/docker.test.ts
@@ -16,6 +16,7 @@ import {
cloneWorkspaceForSandbox,
allocateSandboxWorkspaceDir,
pullImageIfNeeded,
+ resolveDefaultDockerImageTag,
_dockerExec,
_uidDetect,
type DockerRunConfig,
@@ -72,6 +73,29 @@ test("resolveDockerConfig: defaults when no in-file and no env — Docker on", (
assert.equal(cfg.timeoutSeconds, 3600);
});
+test("resolveDefaultDockerImageTag: curl-installer layout (package.json beside src/)", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-installer-"));
+ const runtimeDir = join(root, "src", "runtime");
+ mkdirSync(runtimeDir, { recursive: true });
+ writeFileSync(join(root, "package.json"), JSON.stringify({ version: "9.8.7" }), "utf8");
+ assert.equal(resolveDefaultDockerImageTag(runtimeDir), "9.8.7");
+});
+
+test("resolveDefaultDockerImageTag: npm / dist/src/runtime layout", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-npm-"));
+ const runtimeDir = join(root, "dist", "src", "runtime");
+ mkdirSync(runtimeDir, { recursive: true });
+ writeFileSync(join(root, "package.json"), JSON.stringify({ version: "1.2.3" }), "utf8");
+ assert.equal(resolveDefaultDockerImageTag(runtimeDir), "1.2.3");
+});
+
+test("resolveDefaultDockerImageTag: falls back to nightly when no package.json", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-no-pkg-"));
+ const runtimeDir = join(root, "dist", "src", "runtime");
+ mkdirSync(runtimeDir, { recursive: true });
+ assert.equal(resolveDefaultDockerImageTag(runtimeDir), "nightly");
+});
+
test("resolveDockerConfig: in-file image/timeout overrides defaults (dockerEnabled removed)", () => {
const cfg = resolveDockerConfig(
{ dockerImage: "alpine:3.19", dockerTimeoutSeconds: 60 },
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index 723d2287..e8ec72f4 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -52,16 +52,27 @@ export function validateMountHostPath(hostAbsPath: string): void {
// Config resolution (env > in-file > defaults)
// ---------------------------------------------------------------------------
-/** Read the package version to derive the default GHCR image tag. */
-function resolveDefaultImageTag(): string {
- try {
- const pkgPath = resolve(__dirname, "..", "..", "..", "package.json");
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
- if (pkg.version && typeof pkg.version === "string") {
- return pkg.version;
+/**
+ * Read the jaiph package version to derive the default GHCR image tag.
+ *
+ * Tries two relative layouts:
+ * - Installer (`docs/install`): `…/libDir/package.json` next to `libDir/src/runtime/` (two hops up).
+ * - npm / repo build: `…/pkg/package.json` from `pkg/dist/src/runtime/` (three hops up).
+ */
+export function resolveDefaultDockerImageTag(moduleDir: string = __dirname): string {
+ const candidates = [
+ resolve(moduleDir, "..", "..", "package.json"),
+ resolve(moduleDir, "..", "..", "..", "package.json"),
+ ];
+ for (const pkgPath of candidates) {
+ try {
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
+ if (pkg.version && typeof pkg.version === "string") {
+ return pkg.version;
+ }
+ } catch {
+ // Try next candidate.
}
- } catch {
- // Fall through to nightly.
}
return "nightly";
}
@@ -70,7 +81,7 @@ export const GHCR_IMAGE_REPO = "ghcr.io/jaiphlang/jaiph-runtime";
const DEFAULTS: DockerRunConfig = {
enabled: false,
- image: `${GHCR_IMAGE_REPO}:${resolveDefaultImageTag()}`,
+ image: `${GHCR_IMAGE_REPO}:${resolveDefaultDockerImageTag()}`,
imageExplicit: false,
network: "default",
timeoutSeconds: 3600,
@@ -271,7 +282,7 @@ export function resolveImage(config: DockerRunConfig): string {
* Container-side fuse-overlayfs setup loaded from runtime/overlay-run.sh.
*
* Resolves the file relative to package root — works from both source and dist
- * layouts, mirroring the approach used by `resolveDefaultImageTag`.
+ * layouts, mirroring `resolveDefaultDockerImageTag` (package.json hops).
*/
const OVERLAY_SCRIPT = readFileSync(
existsSync(resolve(__dirname, "overlay-run.sh"))
diff --git a/src/runtime/kernel/emit.test.ts b/src/runtime/kernel/emit.test.ts
index 1569622f..772ca34b 100644
--- a/src/runtime/kernel/emit.test.ts
+++ b/src/runtime/kernel/emit.test.ts
@@ -1,13 +1,10 @@
import assert from "node:assert/strict";
-import { spawnSync } from "node:child_process";
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it } from "node:test";
import { appendRunSummaryLine, formatUtcTimestamp } from "./emit";
-const emitJs = join(__dirname, "emit.js");
-
describe("emit kernel", () => {
it("formatUtcTimestamp matches no-millis Z suffix", () => {
const s = formatUtcTimestamp();
@@ -20,7 +17,6 @@ describe("emit kernel", () => {
try {
const summary = join(dir, "run_summary.jsonl");
process.env.JAIPH_RUN_SUMMARY_FILE = summary;
- delete process.env.JAIPH_INBOX_PARALLEL;
appendRunSummaryLine('{"type":"X","event_version":1}');
const text = readFileSync(summary, "utf8");
assert.equal(text.trim(), '{"type":"X","event_version":1}');
@@ -29,195 +25,4 @@ describe("emit kernel", () => {
rmSync(dir, { recursive: true, force: true });
}
});
-
- it("live mode writes __JAIPH_EVENT__ and LOG summary line", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-live-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const payload = '{"type":"LOG","message":"hi","depth":1}';
- const r = spawnSync(process.execPath, [emitJs, "live"], {
- input: `${payload}\n`,
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_RUN_ID: "run-z",
- JAIPH_EVENT_FD: "2",
- },
- encoding: "utf8",
- });
- assert.equal(r.status, 0, r.stderr);
- assert.ok((r.stderr ?? "").includes("__JAIPH_EVENT__"));
- assert.ok((r.stderr ?? "").includes('"type":"LOG"'));
- const lines = readFileSync(summary, "utf8").trim().split("\n");
- assert.equal(lines.length, 1);
- const row = JSON.parse(lines[0]!) as Record;
- assert.equal(row.type, "LOG");
- assert.equal(row.message, "hi");
- assert.equal(row.depth, 1);
- assert.equal(row.run_id, "run-z");
- assert.equal(row.event_version, 1);
- assert.equal(typeof row.ts, "string");
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("summary-line mode appends caller-built JSON", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-sum-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const line = '{"type":"WORKFLOW_START","workflow":"w","source":"f.jh","ts":"2020-01-01T00:00:00Z","run_id":"r","event_version":1}';
- const r = spawnSync(process.execPath, [emitJs, "summary-line"], {
- input: `${line}\n`,
- env: { ...process.env, JAIPH_RUN_SUMMARY_FILE: summary },
- encoding: "utf8",
- });
- assert.equal(r.status, 0, r.stderr);
- assert.equal(readFileSync(summary, "utf8").trim(), line);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("live STEP_END persists event_version without re-emitting to stderr twice", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-step-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const payload =
- '{"type":"STEP_END","func":"f","kind":"step","name":"n","ts":"2020-01-01T00:00:01Z","status":0,"elapsed_ms":1,"out_file":"","err_file":"","id":"sid","parent_id":null,"seq":1,"depth":0,"run_id":"r"}';
- const r = spawnSync(process.execPath, [emitJs, "live"], {
- input: `${payload}\n`,
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_EVENT_FD: "2",
- },
- encoding: "utf8",
- });
- assert.equal(r.status, 0, r.stderr);
- const errLines = (r.stderr ?? "").split("\n").filter(Boolean);
- assert.equal(errLines.filter((l) => l.startsWith("__JAIPH_EVENT__")).length, 1);
- const sum = JSON.parse(readFileSync(summary, "utf8").trim()) as Record;
- assert.equal(sum.event_version, 1);
- assert.equal(sum.type, "STEP_END");
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("step-event mode builds STEP_START JSON from args", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-se-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const r = spawnSync(
- process.execPath,
- [emitJs, "step-event", "STEP_START", "mod::fn", "workflow", "", "", "", "", "sid", "pid", "1", "0", "key1=val1"],
- {
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_RUN_ID: "run-t",
- JAIPH_EVENT_FD: "2",
- JAIPH_STEP_PARAM_KEYS: "key1",
- },
- encoding: "utf8",
- },
- );
- assert.equal(r.status, 0, r.stderr);
- assert.ok((r.stderr ?? "").includes("__JAIPH_EVENT__"));
- const sum = JSON.parse(readFileSync(summary, "utf8").trim()) as Record;
- assert.equal(sum.type, "STEP_START");
- assert.equal(sum.func, "mod::fn");
- assert.equal(sum.kind, "workflow");
- assert.equal(sum.name, "fn");
- assert.equal(sum.run_id, "run-t");
- assert.equal(sum.event_version, 1);
- assert.deepEqual(sum.params, [["key1", "val1"]]);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("step-event STEP_END embeds out_content from file", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-end-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const outFile = join(dir, "step.out");
- writeFileSync(outFile, "hello output");
- const r = spawnSync(
- process.execPath,
- [emitJs, "step-event", "STEP_END", "mod::fn", "script", "0", "100", outFile, "", "sid", "", "1", "0"],
- {
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_RUN_ID: "run-t",
- JAIPH_EVENT_FD: "2",
- },
- encoding: "utf8",
- },
- );
- assert.equal(r.status, 0, r.stderr);
- const sum = JSON.parse(readFileSync(summary, "utf8").trim()) as Record;
- assert.equal(sum.type, "STEP_END");
- assert.equal(sum.status, 0);
- assert.equal(sum.elapsed_ms, 100);
- assert.equal(sum.out_content, "hello output");
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("log mode builds LOG JSON with depth from JAIPH_STEP_STACK", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-log-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const r = spawnSync(process.execPath, [emitJs, "log", "hello world"], {
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_RUN_ID: "run-l",
- JAIPH_EVENT_FD: "2",
- JAIPH_STEP_STACK: "a,b",
- },
- encoding: "utf8",
- });
- assert.equal(r.status, 0, r.stderr);
- assert.ok((r.stderr ?? "").includes("__JAIPH_EVENT__"));
- assert.ok((r.stderr ?? "").includes('"depth":2'));
- const sum = JSON.parse(readFileSync(summary, "utf8").trim()) as Record;
- assert.equal(sum.type, "LOG");
- assert.equal(sum.message, "hello world");
- assert.equal(sum.depth, 2);
- assert.equal(sum.run_id, "run-l");
- assert.equal(sum.event_version, 1);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("workflow-event mode builds summary-only WORKFLOW_START", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-emit-wf-"));
- try {
- const summary = join(dir, "run_summary.jsonl");
- const r = spawnSync(process.execPath, [emitJs, "workflow-event", "WORKFLOW_START", "default"], {
- env: {
- ...process.env,
- JAIPH_RUN_SUMMARY_FILE: summary,
- JAIPH_RUN_ID: "run-w",
- JAIPH_SOURCE_FILE: "main.jh",
- },
- encoding: "utf8",
- });
- assert.equal(r.status, 0, r.stderr);
- const sum = JSON.parse(readFileSync(summary, "utf8").trim()) as Record;
- assert.equal(sum.type, "WORKFLOW_START");
- assert.equal(sum.workflow, "default");
- assert.equal(sum.source, "main.jh");
- assert.equal(sum.run_id, "run-w");
- assert.equal(sum.event_version, 1);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
});
diff --git a/src/runtime/kernel/emit.ts b/src/runtime/kernel/emit.ts
index 9b8322f7..ced87dd2 100644
--- a/src/runtime/kernel/emit.ts
+++ b/src/runtime/kernel/emit.ts
@@ -1,17 +1,8 @@
/**
- * Runtime event emission: __JAIPH_EVENT__ JSONL on the event fd + matching run_summary.jsonl lines.
- * JS is the source of truth for event JSON building. Bash stdlib delegates here with raw args.
- *
- * Modes (argv[2]):
- * - step-event — args = event fields + optional param args. Builds full STEP_START/STEP_END JSON.
- * - log / logerr — args = message. Builds LOG/LOGERR JSON (depth from JAIPH_STEP_STACK env).
- * - workflow-event — args = type, name. Builds WORKFLOW_START/WORKFLOW_END summary JSON.
- * - live — (legacy) stdin = one JSON object. Writes event line, then summary when applicable.
- * - summary-line — stdin = one complete summary JSON line (workflow / inbox / any caller-built line).
+ * Runtime event emission helpers used by the Node workflow runtime.
*/
-import { appendFileSync, existsSync, mkdirSync, readFileSync, writeSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { acquireLock, releaseLock } from "./fs-lock";
+import { appendFileSync, mkdirSync } from "node:fs";
+import { dirname } from "node:path";
/** UTC timestamp matching `date -u +"%Y-%m-%dT%H:%M:%SZ"` (no milliseconds). */
export function formatUtcTimestamp(): string {
@@ -24,202 +15,5 @@ export function appendRunSummaryLine(line: string): void {
const file = process.env.JAIPH_RUN_SUMMARY_FILE;
if (!file) return;
mkdirSync(dirname(file), { recursive: true });
- const parallel = process.env.JAIPH_INBOX_PARALLEL === "true";
- const lockPath = `${file}.lock`;
- if (parallel) {
- if (!acquireLock(lockPath)) process.exit(1);
- }
- try {
- appendFileSync(file, `${line}\n`, { flag: "a" });
- } finally {
- if (parallel) {
- releaseLock(lockPath);
- }
- }
-}
-
-function writeLivePayload(obj: Record): void {
- const rawFd = process.env.JAIPH_EVENT_FD;
- const fd = rawFd !== undefined && rawFd !== "" ? parseInt(rawFd, 10) : 2;
- const body = `__JAIPH_EVENT__ ${JSON.stringify(obj)}\n`;
- try {
- writeSync(fd, body);
- } catch {
- writeSync(2, body);
- }
-}
-
-function readStdinJsonLine(): Record {
- const input = readFileSync(0, "utf8");
- const line = input.replace(/\r?\n$/, "");
- return JSON.parse(line) as Record;
-}
-
-function emitLive(): void {
- const obj = readStdinJsonLine();
- writeLivePayload(obj);
- const t = obj.type;
- if (!process.env.JAIPH_RUN_SUMMARY_FILE) {
- return;
- }
- if (t === "LOG" || t === "LOGERR") {
- const summary: Record = {
- ...obj,
- ts: formatUtcTimestamp(),
- run_id: process.env.JAIPH_RUN_ID ?? "",
- event_version: 1,
- };
- appendRunSummaryLine(JSON.stringify(summary));
- return;
- }
- if (t === "STEP_START" || t === "STEP_END") {
- const summary: Record = { ...obj, event_version: 1 };
- appendRunSummaryLine(JSON.stringify(summary));
- }
-}
-
-function summaryLine(): void {
- const input = readFileSync(0, "utf8");
- const line = input.replace(/\r?\n$/, "");
- appendRunSummaryLine(line);
-}
-
-function stepIdentity(funcName: string, stepKind: string): { kind: string; name: string } {
- if (stepKind) {
- const name = funcName.split("::").pop() ?? funcName;
- return { kind: stepKind, name };
- }
- if (funcName === "jaiph::prompt") return { kind: "prompt", name: "prompt" };
- return { kind: "step", name: funcName };
-}
-
-function stepStackDepth(): number {
- const stack = process.env.JAIPH_STEP_STACK ?? "";
- return stack ? stack.split(",").length : 0;
-}
-
-function buildStepParams(args: string[]): [string, string][] {
- const keys = process.env.JAIPH_STEP_PARAM_KEYS ?? "";
- if (keys) {
- const keyArr = keys.split(",");
- return keyArr.map((key, i): [string, string] => {
- const arg = args[i] ?? "";
- const val = arg.startsWith(`${key}=`) ? arg.slice(key.length + 1) : arg;
- return [key, val];
- });
- }
- return args.map((arg, i): [string, string] => [`arg${i + 1}`, arg]);
-}
-
-/** step-event: build full STEP_START/STEP_END JSON from positional args + env. */
-function emitStepEvent(): void {
- const a = process.argv;
- const eventType = a[3] ?? "";
- const funcName = a[4] ?? "";
- const stepKind = a[5] ?? "";
- const statusRaw = a[6] ?? "";
- const elapsedMsRaw = a[7] ?? "";
- const outFile = a[8] ?? "";
- const errFile = a[9] ?? "";
- const stepId = a[10] ?? "";
- const parentId = a[11] ?? "";
- const seqRaw = a[12] ?? "";
- const depthRaw = a[13] ?? "";
- const paramArgs = a.slice(14);
-
- const { kind, name } = stepIdentity(funcName, stepKind);
- const status = statusRaw ? parseInt(statusRaw, 10) : null;
- const payload: Record = {
- type: eventType,
- func: funcName,
- kind,
- name,
- ts: formatUtcTimestamp(),
- status,
- elapsed_ms: elapsedMsRaw ? parseInt(elapsedMsRaw, 10) : null,
- out_file: outFile,
- err_file: errFile,
- id: stepId,
- parent_id: parentId || null,
- seq: seqRaw ? parseInt(seqRaw, 10) : null,
- depth: depthRaw ? parseInt(depthRaw, 10) : null,
- run_id: process.env.JAIPH_RUN_ID ?? "",
- };
- if (paramArgs.length > 0) {
- payload.params = buildStepParams(paramArgs);
- }
- if (process.env.JAIPH_DISPATCH_CHANNEL) {
- payload.dispatched = true;
- payload.channel = process.env.JAIPH_DISPATCH_CHANNEL;
- if (process.env.JAIPH_DISPATCH_SENDER) {
- payload.sender = process.env.JAIPH_DISPATCH_SENDER;
- }
- }
- const MAX_EMBED = 1048576;
- if (eventType === "STEP_END") {
- if (outFile && existsSync(outFile)) {
- let c = readFileSync(outFile, "utf8");
- if (c.length > MAX_EMBED) c = `${c.slice(0, MAX_EMBED)}\n[truncated]`;
- payload.out_content = c;
- }
- if (errFile && existsSync(errFile) && status !== 0) {
- let c = readFileSync(errFile, "utf8");
- if (c.length > MAX_EMBED) c = `${c.slice(0, MAX_EMBED)}\n[truncated]`;
- payload.err_content = c;
- }
- }
- writeLivePayload(payload);
- if (process.env.JAIPH_RUN_SUMMARY_FILE) {
- appendRunSummaryLine(JSON.stringify({ ...payload, event_version: 1 }));
- }
-}
-
-/** log / logerr: build LOG or LOGERR JSON from args + JAIPH_STEP_STACK env. */
-function emitLogEvent(type: "LOG" | "LOGERR"): void {
- const message = process.argv[3] ?? "";
- const depth = stepStackDepth();
- const payload = { type, message, depth };
- writeLivePayload(payload);
- if (process.env.JAIPH_RUN_SUMMARY_FILE) {
- appendRunSummaryLine(
- JSON.stringify({
- ...payload,
- ts: formatUtcTimestamp(),
- run_id: process.env.JAIPH_RUN_ID ?? "",
- event_version: 1,
- }),
- );
- }
-}
-
-/** workflow-event: build WORKFLOW_START/WORKFLOW_END summary-only JSON. */
-function emitWorkflowEvent(): void {
- const wfType = process.argv[3] ?? "";
- const wfName = process.argv[4] ?? "";
- appendRunSummaryLine(
- JSON.stringify({
- type: wfType,
- workflow: wfName,
- source: process.env.JAIPH_SOURCE_FILE ?? "",
- ts: formatUtcTimestamp(),
- run_id: process.env.JAIPH_RUN_ID ?? "",
- event_version: 1,
- }),
- );
-}
-
-function main(): void {
- const mode = process.argv[2];
- if (mode === "step-event") { emitStepEvent(); return; }
- if (mode === "log") { emitLogEvent("LOG"); return; }
- if (mode === "logerr") { emitLogEvent("LOGERR"); return; }
- if (mode === "workflow-event") { emitWorkflowEvent(); return; }
- if (mode === "live") { emitLive(); return; }
- if (mode === "summary-line") { summaryLine(); return; }
- process.stderr.write("jaiph emit: unknown mode\n");
- process.exit(1);
-}
-
-if (resolve(process.argv[1] ?? "") === resolve(__filename)) {
- main();
+ appendFileSync(file, `${line}\n`, { flag: "a" });
}
diff --git a/src/runtime/kernel/fs-lock.test.ts b/src/runtime/kernel/fs-lock.test.ts
deleted file mode 100644
index 6a1243b4..00000000
--- a/src/runtime/kernel/fs-lock.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { acquireLock, releaseLock } from "./fs-lock";
-
-test("acquireLock: creates lock directory and pid file", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-lock-"));
- const lockdir = join(dir, "test.lock");
- try {
- const result = acquireLock(lockdir);
- assert.equal(result, true);
- assert.ok(existsSync(lockdir));
- assert.ok(existsSync(join(lockdir, "pid")));
- const pid = readFileSync(join(lockdir, "pid"), "utf8").trim();
- assert.equal(pid, String(process.pid));
- } finally {
- releaseLock(lockdir);
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("releaseLock: removes lock directory and pid file", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-lock-"));
- const lockdir = join(dir, "test.lock");
- try {
- acquireLock(lockdir);
- assert.ok(existsSync(lockdir));
- releaseLock(lockdir);
- assert.ok(!existsSync(lockdir));
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("acquireLock: succeeds after releaseLock", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-lock-"));
- const lockdir = join(dir, "test.lock");
- try {
- assert.equal(acquireLock(lockdir), true);
- releaseLock(lockdir);
- assert.equal(acquireLock(lockdir), true);
- } finally {
- releaseLock(lockdir);
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("acquireLock: cleans up stale lock from dead process", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-lock-"));
- const lockdir = join(dir, "test.lock");
- try {
- // Simulate a stale lock from a non-existent PID
- mkdirSync(lockdir);
- writeFileSync(join(lockdir, "pid"), "999999999\n");
- const result = acquireLock(lockdir);
- assert.equal(result, true);
- const pid = readFileSync(join(lockdir, "pid"), "utf8").trim();
- assert.equal(pid, String(process.pid));
- } finally {
- releaseLock(lockdir);
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("acquireLock: times out when lock held by live process", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-lock-"));
- const lockdir = join(dir, "test.lock");
- const origTimeout = process.env.JAIPH_LOCK_TIMEOUT_SECONDS;
- const origSleep = process.env.JAIPH_LOCK_SLEEP_SECONDS;
- try {
- // Create lock held by current process (which is alive)
- mkdirSync(lockdir);
- writeFileSync(join(lockdir, "pid"), `${process.pid}\n`);
- // Set very short timeout
- process.env.JAIPH_LOCK_TIMEOUT_SECONDS = "0";
- process.env.JAIPH_LOCK_SLEEP_SECONDS = "0.001";
- const result = acquireLock(lockdir);
- assert.equal(result, false);
- } finally {
- if (origTimeout === undefined) delete process.env.JAIPH_LOCK_TIMEOUT_SECONDS;
- else process.env.JAIPH_LOCK_TIMEOUT_SECONDS = origTimeout;
- if (origSleep === undefined) delete process.env.JAIPH_LOCK_SLEEP_SECONDS;
- else process.env.JAIPH_LOCK_SLEEP_SECONDS = origSleep;
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("releaseLock: no-op on nonexistent lock", () => {
- releaseLock("/tmp/jaiph-nonexistent-lock-dir-12345");
-});
diff --git a/src/runtime/kernel/fs-lock.ts b/src/runtime/kernel/fs-lock.ts
deleted file mode 100644
index b7bed01d..00000000
--- a/src/runtime/kernel/fs-lock.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Portable mkdir-based locks.
- */
-import { existsSync, mkdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-
-function sleepMs(ms: number): void {
- const end = Date.now() + ms;
- while (Date.now() < end) {
- /* busy wait — matches bash lock polling without shelling out */
- }
-}
-
-function isProcessAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch (e: unknown) {
- const err = e as NodeJS.ErrnoException;
- if (err.code === "EPERM") return true;
- return false;
- }
-}
-
-export function acquireLock(lockdir: string): boolean {
- const timeoutRaw = process.env.JAIPH_LOCK_TIMEOUT_SECONDS ?? "30";
- const timeoutS = /^\d+$/.test(timeoutRaw) ? parseInt(timeoutRaw, 10) : 30;
- let sleepMsVal = 50;
- const sleepRaw = process.env.JAIPH_LOCK_SLEEP_SECONDS;
- if (sleepRaw !== undefined && sleepRaw !== "") {
- const parsed = parseFloat(sleepRaw);
- if (!Number.isNaN(parsed) && parsed >= 0) sleepMsVal = Math.round(parsed * 1000);
- }
- const started = Date.now();
- while (true) {
- try {
- mkdirSync(lockdir);
- writeFileSync(join(lockdir, "pid"), `${process.pid}\n`);
- return true;
- } catch {
- const pidPath = join(lockdir, "pid");
- if (existsSync(pidPath)) {
- const ownerRaw = readFileSync(pidPath, "utf8").trim();
- const owner = parseInt(ownerRaw, 10);
- if (owner > 0 && !isProcessAlive(owner)) {
- try {
- unlinkSync(pidPath);
- } catch {
- /* ignore */
- }
- try {
- rmdirSync(lockdir);
- } catch {
- /* ignore */
- }
- continue;
- }
- }
- if (Date.now() - started >= timeoutS * 1000) {
- process.stderr.write(`jaiph: lock timeout while waiting for ${lockdir}\n`);
- return false;
- }
- sleepMs(sleepMsVal);
- }
- }
-}
-
-export function releaseLock(lockdir: string): void {
- try {
- unlinkSync(join(lockdir, "pid"));
- } catch {
- /* ignore */
- }
- try {
- rmdirSync(lockdir);
- } catch {
- /* ignore */
- }
-}
diff --git a/src/runtime/kernel/mock.test.ts b/src/runtime/kernel/mock.test.ts
index 4db40990..99809725 100644
--- a/src/runtime/kernel/mock.test.ts
+++ b/src/runtime/kernel/mock.test.ts
@@ -1,92 +1,76 @@
-import { describe, it, beforeEach, afterEach } from "node:test";
+import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
-import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
-import { join } from "node:path";
-import { tmpdir } from "node:os";
-import { readNextMockResponse, mockDispatch } from "./mock";
+import { consumeNextMockResponse, dispatchMockArms } from "./mock";
-function tmpFile(name: string): string {
- const dir = join(tmpdir(), `jaiph-mock-test-${process.pid}`);
- try { mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
- return join(dir, name);
-}
-
-describe("readNextMockResponse", () => {
- let mockFile: string;
-
- beforeEach(() => {
- mockFile = tmpFile("mock-responses.txt");
- });
-
- afterEach(() => {
- try { unlinkSync(mockFile); } catch { /* ok */ }
+describe("consumeNextMockResponse", () => {
+ it("returns responses in order, then null when exhausted", () => {
+ const json = JSON.stringify(["first", "second", "third"]);
+ assert.equal(consumeNextMockResponse(json), "first");
+ assert.equal(consumeNextMockResponse(json), "second");
+ assert.equal(consumeNextMockResponse(json), "third");
+ assert.equal(consumeNextMockResponse(json), null);
});
- it("reads and consumes first line", () => {
- writeFileSync(mockFile, "first\nsecond\nthird\n", "utf8");
- const result = readNextMockResponse(mockFile);
- assert.equal(result, "first");
- const remaining = readFileSync(mockFile, "utf8");
- assert.equal(remaining, "second\nthird\n");
+ it("re-seeds when JSON changes", () => {
+ consumeNextMockResponse(JSON.stringify(["a"]));
+ const result = consumeNextMockResponse(JSON.stringify(["b", "c"]));
+ assert.equal(result, "b");
});
- it("returns null when file is empty", () => {
- writeFileSync(mockFile, "", "utf8");
- const result = readNextMockResponse(mockFile);
- assert.equal(result, null);
- });
-
- it("returns null for missing file", () => {
+ it("returns null on invalid JSON", () => {
const origWrite = process.stderr.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
- const result = readNextMockResponse("/nonexistent/path");
+ const result = consumeNextMockResponse("not-json");
process.stderr.write = origWrite;
assert.equal(result, null);
});
-
- it("handles single-line file", () => {
- writeFileSync(mockFile, "only-line\n", "utf8");
- const result = readNextMockResponse(mockFile);
- assert.equal(result, "only-line");
- });
});
-describe("mockDispatch", () => {
- let scriptPath: string;
-
- beforeEach(() => {
- scriptPath = tmpFile("dispatch.sh");
- });
-
- afterEach(() => {
- try { unlinkSync(scriptPath); } catch { /* ok */ }
+describe("dispatchMockArms", () => {
+ it("matches a string-literal arm exactly", () => {
+ const result = dispatchMockArms("hello", [
+ { kind: "string", pattern: "hello", response: "world" },
+ { kind: "wildcard", response: "fallback" },
+ ]);
+ assert.equal(result.status, 0);
+ assert.equal(result.response, "world");
});
- it("runs script and returns stdout", () => {
- writeFileSync(scriptPath, '#!/bin/bash\necho "mock-response"', { mode: 0o755 });
- const result = mockDispatch("test prompt", scriptPath);
+ it("matches a regex arm when pattern matches", () => {
+ const result = dispatchMockArms("foo-123", [
+ { kind: "regex", pattern: "^foo-\\d+$", response: "matched" },
+ { kind: "wildcard", response: "fallback" },
+ ]);
assert.equal(result.status, 0);
- assert.equal(result.response.trim(), "mock-response");
+ assert.equal(result.response, "matched");
});
- it("returns non-zero status on script failure", () => {
- writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
- const result = mockDispatch("test prompt", scriptPath);
- assert.equal(result.status, 1);
+ it("falls through to wildcard when no other arm matches", () => {
+ const result = dispatchMockArms("anything", [
+ { kind: "string", pattern: "specific", response: "no" },
+ { kind: "wildcard", response: "default" },
+ ]);
+ assert.equal(result.status, 0);
+ assert.equal(result.response, "default");
});
- it("returns status 1 for missing script", () => {
+ it("returns status 1 with no match and no wildcard", () => {
const origWrite = process.stderr.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
- const result = mockDispatch("test", "/nonexistent/script");
+ const result = dispatchMockArms("nothing matches", [
+ { kind: "string", pattern: "foo", response: "x" },
+ ]);
process.stderr.write = origWrite;
assert.equal(result.status, 1);
+ assert.equal(result.response, "");
});
- it("passes prompt text as first argument", () => {
- writeFileSync(scriptPath, '#!/bin/bash\nprintf "%s" "$1"', { mode: 0o755 });
- const result = mockDispatch("hello world", scriptPath);
+ it("first matching arm wins", () => {
+ const result = dispatchMockArms("hello", [
+ { kind: "regex", pattern: "^h", response: "first" },
+ { kind: "regex", pattern: "lo$", response: "second" },
+ ]);
assert.equal(result.status, 0);
- assert.equal(result.response, "hello world");
+ assert.equal(result.response, "first");
});
});
diff --git a/src/runtime/kernel/mock.ts b/src/runtime/kernel/mock.ts
index ac656327..635d7dc0 100644
--- a/src/runtime/kernel/mock.ts
+++ b/src/runtime/kernel/mock.ts
@@ -1,53 +1,56 @@
-// JS kernel: test-mode mock helpers.
// Test-mode mock response and dispatch helpers.
-import { readFileSync, writeFileSync, existsSync } from "node:fs";
-import { execFileSync } from "node:child_process";
-
/**
- * Read and consume the first line from the mock responses file.
- * Returns the line or null if empty/missing.
+ * Process-local queue of sequential prompt responses, populated from the JSON
+ * env var on first call and consumed in order. Re-seeds when the JSON changes
+ * (different test block / different workflow run).
*/
-export function readNextMockResponse(filePath: string): string | null {
- if (!filePath || !existsSync(filePath)) {
- process.stderr.write("jaiph: no mock for prompt (JAIPH_MOCK_RESPONSES_FILE missing or not a file)\n");
- return null;
- }
- const content = readFileSync(filePath, "utf8");
- const lines = content.split("\n");
- const firstLine = lines[0];
- if (!firstLine) return null;
- // Consume: write remaining lines back
- const remaining = lines.slice(1).join("\n");
- try {
- writeFileSync(filePath, remaining, "utf8");
- } catch {
- // best-effort
+let cachedResponsesJson = "";
+let responsesQueue: string[] = [];
+
+/** Take the next sequential mock response. Returns null when the queue is empty. */
+export function consumeNextMockResponse(json: string): string | null {
+ if (json !== cachedResponsesJson) {
+ cachedResponsesJson = json;
+ try {
+ responsesQueue = JSON.parse(json) as string[];
+ } catch {
+ process.stderr.write("jaiph: invalid JAIPH_MOCK_RESPONSES_JSON\n");
+ return null;
+ }
}
- return firstLine;
+ return responsesQueue.shift() ?? null;
}
+/** Serialised arm form passed via JAIPH_MOCK_PROMPT_ARMS_JSON. */
+export type MockPromptArm =
+ | { kind: "string"; pattern: string; response: string }
+ | { kind: "regex"; pattern: string; response: string }
+ | { kind: "wildcard"; response: string };
+
/**
- * Run the mock dispatch script with prompt text as $1.
- * Returns { response, status }.
+ * Match a prompt against the arms (in order), returning the first matching response.
+ * If no arm matches and no wildcard is present, returns status=1 with a clear stderr.
*/
-export function mockDispatch(
+export function dispatchMockArms(
promptText: string,
- scriptPath: string,
+ arms: MockPromptArm[],
): { response: string; status: number } {
- if (!scriptPath || !existsSync(scriptPath)) {
- process.stderr.write("jaiph: no mock for prompt (JAIPH_MOCK_DISPATCH_SCRIPT missing or not executable)\n");
- return { response: "", status: 1 };
- }
- try {
- const result = execFileSync(scriptPath, [promptText], {
- encoding: "utf8",
- stdio: ["pipe", "pipe", "pipe"],
- });
- return { response: result, status: 0 };
- } catch (err: unknown) {
- const execErr = err as { status?: number; stderr?: string };
- if (execErr.stderr) process.stderr.write(execErr.stderr);
- return { response: "", status: execErr.status ?? 1 };
+ for (const arm of arms) {
+ if (arm.kind === "string") {
+ if (promptText === arm.pattern) return { response: arm.response, status: 0 };
+ } else if (arm.kind === "regex") {
+ try {
+ if (new RegExp(arm.pattern).test(promptText)) return { response: arm.response, status: 0 };
+ } catch {
+ // invalid regex — fall through to next arm
+ }
+ } else {
+ return { response: arm.response, status: 0 };
+ }
}
+ process.stderr.write(
+ `jaiph: no mock matched prompt (no branch matched). Prompt preview: ${promptText.slice(0, 80)}...\n`,
+ );
+ return { response: "", status: 1 };
}
diff --git a/src/runtime/kernel/node-test-runner.ts b/src/runtime/kernel/node-test-runner.ts
index 6860ae89..4e7fd597 100644
--- a/src/runtime/kernel/node-test-runner.ts
+++ b/src/runtime/kernel/node-test-runner.ts
@@ -1,8 +1,9 @@
-import { mkdtempSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from "node:fs";
+import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { buildRuntimeGraph, resolveWorkflowRef, resolveRuleRef, resolveScriptRef, type RuntimeGraph } from "./graph";
import { NodeWorkflowRuntime, type MockBodyDef } from "./node-workflow-runtime";
+import type { MockPromptArm } from "./mock";
import type { TestBlockDef, TestStepDef } from "../../types";
type TestResult = { pass: boolean; error?: string };
@@ -64,38 +65,19 @@ function resolveMockBodies(
return bodies;
}
-function writeMockDispatchScript(
+function buildMockArms(
step: Extract,
- dir: string,
-): string {
- const escSh = (s: string): string => s.replace(/'/g, "'\\''");
- const lines: string[] = ["#!/usr/bin/env bash", "set -euo pipefail", 'prompt="${1:-}"'];
- let first = true;
- for (const arm of step.arms) {
- const cond = first ? "if" : "elif";
- first = false;
- if (arm.pattern.kind === "string_literal") {
- lines.push(`${cond} [[ "$prompt" == '${escSh(arm.pattern.value)}' ]]; then`);
- } else if (arm.pattern.kind === "regex") {
- lines.push(`${cond} [[ "$prompt" =~ ${arm.pattern.source} ]]; then`);
- } else {
- // wildcard — always matches; emit as else
- lines.push("else");
- }
- const response = arm.body.replace(/^["']|["']$/g, "").replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\\\/g, "\\");
- lines.push(` printf '%s' '${escSh(response)}'`);
- }
- // If no wildcard arm, add a fallback that errors
- if (!step.arms.some((a) => a.pattern.kind === "wildcard")) {
- lines.push("else");
- lines.push(' echo "jaiph: no mock matched prompt (no branch matched). Prompt preview: ${prompt:0:80}..." >&2');
- lines.push(" exit 1");
- }
- lines.push("fi");
- const scriptPath = join(dir, "mock_dispatch.sh");
- writeFileSync(scriptPath, lines.join("\n") + "\n");
- chmodSync(scriptPath, 0o755);
- return scriptPath;
+): MockPromptArm[] {
+ return step.arms.map((arm): MockPromptArm => {
+ const response = arm.body
+ .replace(/^["']|["']$/g, "")
+ .replace(/\\"/g, '"')
+ .replace(/\\n/g, "\n")
+ .replace(/\\\\/g, "\\");
+ if (arm.pattern.kind === "string_literal") return { kind: "string", pattern: arm.pattern.value, response };
+ if (arm.pattern.kind === "regex") return { kind: "regex", pattern: arm.pattern.source, response };
+ return { kind: "wildcard", response };
+ });
}
async function runTestBlock(
@@ -108,7 +90,7 @@ async function runTestBlock(
const tmpDir = mkdtempSync(join(tmpdir(), "jaiph-test-block-"));
try {
const mockResponses: string[] = [];
- let mockDispatchPath = "";
+ let mockArmsJson = "";
const mockRefs: Array> = [];
const vars = new Map();
@@ -136,19 +118,17 @@ async function runTestBlock(
}
}
if (step.type === "test_mock_prompt_block") {
- mockDispatchPath = writeMockDispatchScript(step, tmpDir);
+ mockArmsJson = JSON.stringify(buildMockArms(step));
}
if (step.type === "test_mock_workflow" || step.type === "test_mock_rule" || step.type === "test_mock_script") {
mockRefs.push(step);
}
}
- // Set up mock responses file
- let mockResponsesFile = "";
- if (mockResponses.length > 0 && !mockDispatchPath) {
- mockResponsesFile = join(tmpDir, "mock_responses.txt");
- writeFileSync(mockResponsesFile, mockResponses.join("\n") + "\n");
- }
+ // Encode sequential mock responses as JSON for the in-process runtime queue.
+ const mockResponsesJson = mockResponses.length > 0 && !mockArmsJson
+ ? JSON.stringify(mockResponses)
+ : "";
// Execute test steps
for (const step of block.steps) {
@@ -171,17 +151,18 @@ async function runTestBlock(
JAIPH_RUNS_DIR: join(tmpDir, ".jaiph", "runs"),
JAIPH_SCRIPTS: scriptsDir,
};
- if (mockDispatchPath) {
- env.JAIPH_MOCK_DISPATCH_SCRIPT = mockDispatchPath;
- delete env.JAIPH_MOCK_RESPONSES_FILE;
- } else if (mockResponsesFile) {
- env.JAIPH_MOCK_RESPONSES_FILE = mockResponsesFile;
- delete env.JAIPH_MOCK_DISPATCH_SCRIPT;
+ if (mockArmsJson) {
+ env.JAIPH_MOCK_PROMPT_ARMS_JSON = mockArmsJson;
+ delete env.JAIPH_MOCK_RESPONSES_JSON;
+ } else if (mockResponsesJson) {
+ env.JAIPH_MOCK_RESPONSES_JSON = mockResponsesJson;
+ delete env.JAIPH_MOCK_PROMPT_ARMS_JSON;
}
const runtime = new NodeWorkflowRuntime(graph, {
env,
cwd: workspaceRoot,
mockBodies,
+ suppressLiveEvents: true,
});
const result = await runtime.runNamedWorkflow(step.workflowRef, step.args ?? []);
// Resolve the captured value following production `run_capture` semantics.
diff --git a/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts b/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
index 0e0ac5a4..e0b83340 100644
--- a/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
+++ b/src/runtime/kernel/node-workflow-runtime.artifacts.test.ts
@@ -25,7 +25,7 @@ test("NodeWorkflowRuntime: runDefault writes return_value.txt with the workflow'
JAIPH_TEST_MODE: "1",
JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault(["world"]);
assert.equal(status, 0);
@@ -56,7 +56,7 @@ test("NodeWorkflowRuntime: runDefault does not write return_value.txt when workf
JAIPH_TEST_MODE: "1",
JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault([]);
assert.equal(status, 0);
@@ -80,17 +80,16 @@ test("NodeWorkflowRuntime: prompt step preview preserves authored ${var} placeho
"",
].join("\n"),
);
- const mockFile = join(root, "mocks.txt");
- writeFileSync(mockFile, "ok\n");
+ const mockJson = JSON.stringify(["ok"]);
const graph = buildRuntimeGraph(jh);
const env: NodeJS.ProcessEnv = {
...process.env,
JAIPH_TEST_MODE: "1",
- JAIPH_MOCK_RESPONSES_FILE: mockFile,
+ JAIPH_MOCK_RESPONSES_JSON: mockJson,
JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const prevSummaryEnv = process.env.JAIPH_RUN_SUMMARY_FILE;
process.env.JAIPH_RUN_SUMMARY_FILE = runtime.getSummaryFile();
let status: number;
@@ -135,17 +134,16 @@ test("NodeWorkflowRuntime: workflow step .out accumulates Command:/Prompt: and l
"",
].join("\n"),
);
- const mockFile = join(root, "mocks.txt");
- writeFileSync(mockFile, "mocked-agent-reply\n");
+ const mockJson = JSON.stringify(["mocked-agent-reply"]);
const graph = buildRuntimeGraph(jh);
const env: NodeJS.ProcessEnv = {
...process.env,
JAIPH_TEST_MODE: "1",
- JAIPH_MOCK_RESPONSES_FILE: mockFile,
+ JAIPH_MOCK_RESPONSES_JSON: mockJson,
JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault([]);
assert.equal(status, 0);
@@ -197,7 +195,7 @@ test("NodeWorkflowRuntime: failed prompt preserves backend stderr in artifacts a
JAIPH_AGENT_MODEL: "gpt-5.4",
JAIPH_WORKSPACE: root,
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const prevSummaryEnv = process.env.JAIPH_RUN_SUMMARY_FILE;
process.env.JAIPH_RUN_SUMMARY_FILE = runtime.getSummaryFile();
let status: number;
@@ -281,7 +279,7 @@ test("NodeWorkflowRuntime: ensure catch receives failure payload in catch scope
JAIPH_SCRIPTS: scriptsDir,
JAIPH_WORKSPACE: root,
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault(["original-arg1", "preserved-arg2"]);
assert.equal(status, 0);
@@ -355,7 +353,7 @@ test("NodeWorkflowRuntime: nested workflow inherits caller metadata scope (calle
delete env.JAIPH_AGENT_BACKEND;
delete env.JAIPH_AGENT_BACKEND_LOCKED;
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault([]);
assert.equal(status, 0);
@@ -426,7 +424,7 @@ test("NodeWorkflowRuntime: nested cross-module preserves locked JAIPH_AGENT_BACK
JAIPH_AGENT_BACKEND_LOCKED: "1",
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault([]);
assert.equal(status, 0);
@@ -500,7 +498,7 @@ test("NodeWorkflowRuntime: sibling workflows do not inherit each other's metadat
delete env.JAIPH_AGENT_BACKEND;
delete env.JAIPH_AGENT_BACKEND_LOCKED;
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const status = await runtime.runDefault([]);
assert.equal(status, 0);
@@ -527,18 +525,17 @@ test("NodeWorkflowRuntime: prompt STEP_START params include named vars reference
"",
].join("\n"),
);
- const mockFile = join(root, "mocks.txt");
- writeFileSync(mockFile, "analysis-done\n");
+ const mockJson = JSON.stringify(["analysis-done"]);
const runsDir = join(root, ".jaiph", "runs");
const graph = buildRuntimeGraph(jh);
const env: NodeJS.ProcessEnv = {
...process.env,
JAIPH_TEST_MODE: "1",
- JAIPH_MOCK_RESPONSES_FILE: mockFile,
+ JAIPH_MOCK_RESPONSES_JSON: mockJson,
JAIPH_RUNS_DIR: runsDir,
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
// Bridge env so appendRunSummaryLine (reads process.env) writes the summary.
const prevSummaryEnv = process.env.JAIPH_RUN_SUMMARY_FILE;
process.env.JAIPH_RUN_SUMMARY_FILE = runtime.getSummaryFile();
@@ -580,7 +577,7 @@ test("NodeWorkflowRuntime: JAIPH_ARTIFACTS_DIR is set and points at writable art
JAIPH_TEST_MODE: "1",
JAIPH_RUNS_DIR: runsDir,
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const runDir = runtime.getRunDir();
const artifactsDir = env.JAIPH_ARTIFACTS_DIR;
@@ -611,7 +608,7 @@ test("NodeWorkflowRuntime: JAIPH_ARTIFACTS_DIR resolves under .jaiph/runs when J
const graph = buildRuntimeGraph(jh);
const env: NodeJS.ProcessEnv = { ...process.env, JAIPH_TEST_MODE: "1" };
delete env.JAIPH_RUNS_DIR;
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const artifactsDir = env.JAIPH_ARTIFACTS_DIR;
assert.ok(artifactsDir, "JAIPH_ARTIFACTS_DIR should be set");
@@ -630,17 +627,16 @@ test("NodeWorkflowRuntime: heartbeat file created at construction, removed on st
try {
const jh = join(root, "heartbeat.jh");
writeFileSync(jh, 'workflow default() {\n log "ok"\n}\n');
- const mockFile = join(root, "mocks.txt");
- writeFileSync(mockFile, "");
+ const mockJson = JSON.stringify([""]);
const graph = buildRuntimeGraph(jh);
const env: NodeJS.ProcessEnv = {
...process.env,
JAIPH_TEST_MODE: "1",
- JAIPH_MOCK_RESPONSES_FILE: mockFile,
+ JAIPH_MOCK_RESPONSES_JSON: mockJson,
JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
};
- const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root });
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
const runDir = runtime.getRunDir();
const heartbeatPath = join(runDir, "heartbeat");
@@ -654,3 +650,77 @@ test("NodeWorkflowRuntime: heartbeat file created at construction, removed on st
rmSync(root, { recursive: true, force: true });
}
});
+
+function inboxDispatchStartTargets(summaryPath: string): string[] {
+ const text = readFileSync(summaryPath, "utf8");
+ const order: string[] = [];
+ for (const line of text.split("\n")) {
+ if (!line.trim()) continue;
+ const e = JSON.parse(line) as { type?: string; target?: string };
+ if (e.type === "INBOX_DISPATCH_START" && typeof e.target === "string") {
+ order.push(e.target);
+ }
+ }
+ return order;
+}
+
+test("NodeWorkflowRuntime: JAIPH_INBOX_PARALLEL has no effect on inbox dispatch sequencing", async () => {
+ const src = [
+ "channel results -> consumer_a, consumer_b",
+ "",
+ "workflow producer() {",
+ ' results <- "dispatch-order-payload"',
+ "}",
+ "",
+ "workflow consumer_a(message, chan, sender) {",
+ ' log "consumer_a"',
+ "}",
+ "",
+ "workflow consumer_b(message, chan, sender) {",
+ ' log "consumer_b"',
+ "}",
+ "",
+ "workflow default() {",
+ " run producer()",
+ "}",
+ "",
+ ].join("\n");
+
+ const runOnce = async (inboxParallelEnv: string | undefined): Promise => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-inbox-par-env-"));
+ try {
+ const jh = join(root, "inbox_par.jh");
+ writeFileSync(jh, src);
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ };
+ delete env.JAIPH_INBOX_PARALLEL;
+ if (inboxParallelEnv !== undefined) {
+ env.JAIPH_INBOX_PARALLEL = inboxParallelEnv;
+ }
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const prevSummary = process.env.JAIPH_RUN_SUMMARY_FILE;
+ process.env.JAIPH_RUN_SUMMARY_FILE = runtime.getSummaryFile();
+ let status: number;
+ try {
+ status = await runtime.runDefault([]);
+ } finally {
+ if (prevSummary === undefined) delete process.env.JAIPH_RUN_SUMMARY_FILE;
+ else process.env.JAIPH_RUN_SUMMARY_FILE = prevSummary;
+ }
+ assert.equal(status, 0);
+ runtime.stopHeartbeat();
+ return inboxDispatchStartTargets(runtime.getSummaryFile());
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ };
+
+ const without = await runOnce(undefined);
+ const withTrue = await runOnce("true");
+ assert.deepEqual(without, withTrue);
+ assert.deepEqual(without, ["consumer_a", "consumer_b"]);
+});
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index 8138127b..97ff655e 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
-import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import { PassThrough } from "node:stream";
import { randomUUID } from "node:crypto";
@@ -7,20 +7,34 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { inlineScriptName } from "../../inline-script-name";
import type { MatchExprDef, WorkflowStepDef } from "../../types";
import { executePrompt, resolveConfig, resolveModel, resolvePromptStepName } from "./prompt";
-import { appendRunSummaryLine, formatUtcTimestamp } from "./emit";
+import { appendRunSummaryLine } from "./emit";
import { buildStepDisplayParamPairs } from "../../cli/commands/format-params.js";
import { resolveRuleRef, resolveScriptRef, resolveWorkflowRef, type RuntimeGraph } from "./graph";
import type { WorkflowMetadata } from "../../types";
import { extractJson, validateFields } from "./schema";
-import { parseCallRef } from "../../parse/core";
import {
plainMultilineOrchestrationForRuntime,
tripleQuotedRawForRuntime,
} from "../orchestration-text";
-
-const MAX_EMBED = 1024 * 1024;
-const MAX_RECURSION_DEPTH = 256;
-type EnsureRecover = Extract["recover"];
+import {
+ commaArgsToInterpolated,
+ interpolate,
+ MAX_EMBED,
+ MAX_RECURSION_DEPTH,
+ nowIso,
+ parseArgTokens,
+ parseInlineCaptureCall,
+ parsePromptSchema,
+ sanitizeName,
+ stripOuterQuotes,
+ type PromptSchemaField,
+} from "./runtime-arg-parser";
+import { RuntimeEventEmitter, type Frame } from "./runtime-event-emitter";
+import { executeMockBodyDef, type MockBodyDef, type StepResult } from "./runtime-mock";
+
+export type { MockBodyDef } from "./runtime-mock";
+
+type EnsureRecover = Extract["catch"];
const HANDLE_PREFIX = "__JAIPH_HANDLE__";
@@ -30,11 +44,6 @@ type AsyncHandle = {
resolved?: StepResult;
};
-/** Mock body definition: shell for script mocks, Jaiph steps for workflow/rule mocks. */
-export type MockBodyDef =
- | { kind: "shell"; body: string; params: string[] }
- | { kind: "steps"; steps: WorkflowStepDef[]; params: string[] };
-
type Scope = {
filePath: string;
vars: Map;
@@ -43,21 +52,6 @@ type Scope = {
declaredParamNames?: string[];
};
-type Frame = {
- id: string;
- kind: string;
- name: string;
-};
-
-type StepResult = {
- status: number;
- output: string;
- error: string;
- returnValue?: string;
- /** Set when a catch body executed a `return` statement. */
- recoverReturn?: boolean;
-};
-
type StepIO = {
appendOut: (chunk: string) => void;
appendErr: (chunk: string) => void;
@@ -76,251 +70,6 @@ type WorkflowContext = {
queue: InboxMsg[];
};
-type PromptSchemaField = { name: string; type: "string" | "number" | "boolean" };
-type PromptStepHandle = {
- id: string;
- seq: number;
- outFile: string;
- errFile: string;
- backend: string;
- startedAtMs: number;
-};
-
-function sanitizeName(raw: string): string {
- return raw.replace(/[^a-zA-Z0-9_.-]/g, "_");
-}
-
-function nowIso(): string {
- return formatUtcTimestamp();
-}
-
-function interpolate(input: string, vars: Map, env?: NodeJS.ProcessEnv): string {
- const lookup = (key: string): string => vars.get(key) ?? env?.[key] ?? "";
- return input.replace(/\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\.([a-zA-Z_][a-zA-Z0-9_]*))?\}/g, (_m, base, field) => {
- if (!field) return lookup(String(base));
- // Dot field access: parse JSON stored in the base variable and extract the field.
- const raw = lookup(String(base));
- try {
- const obj = JSON.parse(raw);
- return obj != null && typeof obj === "object" && field in obj ? String(obj[field]) : "";
- } catch {
- return "";
- }
- });
-}
-
-/** Body after "run" / "ensure" in ${run ...} / ${ensure ...} (e.g. greet(), greet(x), or greet x). */
-function parseInlineCaptureCall(body: string): { ref: string; argsRaw: string } {
- const trimmed = body.trim();
- const paren = trimmed.match(/^([\w.]+)\s*\(([^)]*)\)\s*$/);
- if (paren) {
- return { ref: paren[1], argsRaw: paren[2].trim() };
- }
- const spaceIdx = trimmed.indexOf(" ");
- if (spaceIdx === -1) {
- return { ref: trimmed, argsRaw: "" };
- }
- return { ref: trimmed.slice(0, spaceIdx), argsRaw: trimmed.slice(spaceIdx + 1).trim() };
-}
-
-const BARE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
-
-/** Convert comma-separated call args (as written in source) to space-separated form with bare identifiers wrapped in ${…}. */
-function commaArgsToInterpolated(raw: string): string {
- if (!raw.trim()) return "";
- return raw.split(",").map((seg) => {
- const t = seg.trim();
- return BARE_IDENT_RE.test(t) ? `\${${t}}` : t;
- }).join(" ");
-}
-
-function parseArgsRaw(raw: string, vars: Map, env?: NodeJS.ProcessEnv): string[] {
- if (!raw.trim()) return [];
- const out: string[] = [];
- let cur = "";
- let quote: "'" | '"' | null = null;
- for (let i = 0; i < raw.length; i += 1) {
- const ch = raw[i]!;
- if (quote) {
- if (ch === quote) {
- quote = null;
- } else {
- cur += ch;
- }
- continue;
- }
- if (ch === "'" || ch === '"') {
- quote = ch;
- continue;
- }
- if (/\s/.test(ch)) {
- if (cur.length > 0) {
- out.push(interpolate(cur, vars, env));
- cur = "";
- }
- continue;
- }
- cur += ch;
- }
- if (cur.length > 0) {
- out.push(interpolate(cur, vars, env));
- }
- return out;
-}
-
-type ParsedArgToken =
- | { kind: "literal"; value: string }
- | { kind: "managed"; managedKind: "run" | "ensure"; ref: string; argsRaw: string }
- | { kind: "managed_inline_script"; body: string; lang?: string; argsRaw: string };
-
-/** Try to parse `\`body\`(args)` from a string at a given position. */
-function parseInlineScriptAt(s: string): { body: string; argsRaw: string; consumed: number } | null {
- const t = s.trimStart();
- const skippedWs = s.length - t.length;
- if (!t.startsWith("`")) return null;
- const closeIdx = t.indexOf("`", 1);
- if (closeIdx === -1) return null;
- const body = t.slice(1, closeIdx);
- const afterClose = t.slice(closeIdx + 1);
- if (!afterClose.startsWith("(")) return null;
- let depth = 1;
- let i = 1;
- let inQuote: string | null = null;
- while (i < afterClose.length && depth > 0) {
- const ch = afterClose[i];
- if (inQuote) {
- if (ch === inQuote && afterClose[i - 1] !== "\\") inQuote = null;
- } else {
- if (ch === '"' || ch === "'") inQuote = ch;
- else if (ch === "(") depth++;
- else if (ch === ")") depth--;
- }
- i++;
- }
- if (depth !== 0) return null;
- const argsContent = afterClose.slice(1, i - 1).trim();
- return { body, argsRaw: argsContent, consumed: skippedWs + closeIdx + 1 + i };
-}
-
-function parseManagedArgAt(raw: string, start: number): { token: ParsedArgToken; next: number } | null {
- const tail = raw.slice(start);
- const keyword = tail.startsWith("run ")
- ? "run"
- : tail.startsWith("ensure ")
- ? "ensure"
- : null;
- if (!keyword) return null;
- const afterKeyword = raw.slice(start + keyword.length).trimStart();
- const skipped = raw.slice(start + keyword.length).length - afterKeyword.length;
- const call = parseCallRef(afterKeyword);
- if (call && (call.rest.length === 0 || /^\s/.test(call.rest))) {
- const consumed = afterKeyword.length - call.rest.length;
- return {
- token: {
- kind: "managed",
- managedKind: keyword,
- ref: call.ref,
- argsRaw: call.args ?? "",
- },
- next: start + keyword.length + skipped + consumed,
- };
- }
- // Try inline script form: run `body`(args)
- if (keyword === "run") {
- const inlineResult = parseInlineScriptAt(afterKeyword);
- if (inlineResult) {
- return {
- token: {
- kind: "managed_inline_script",
- body: inlineResult.body,
- argsRaw: inlineResult.argsRaw,
- },
- next: start + keyword.length + skipped + inlineResult.consumed,
- };
- }
- }
- return null;
-}
-
-function parseArgTokens(raw: string): ParsedArgToken[] {
- if (!raw.trim()) return [];
- const out: ParsedArgToken[] = [];
- let i = 0;
- while (i < raw.length) {
- while (i < raw.length && /\s/.test(raw[i]!)) i += 1;
- if (i >= raw.length) break;
- const managed = parseManagedArgAt(raw, i);
- if (managed) {
- out.push(managed.token);
- i = managed.next;
- continue;
- }
- let cur = "";
- let quote: "'" | '"' | null = null;
- while (i < raw.length) {
- const ch = raw[i]!;
- if (quote) {
- if (ch === quote) {
- quote = null;
- } else {
- cur += ch;
- }
- i += 1;
- continue;
- }
- if (ch === "'" || ch === '"') {
- quote = ch;
- i += 1;
- continue;
- }
- if (/\s/.test(ch)) {
- break;
- }
- cur += ch;
- i += 1;
- }
- if (cur.length > 0) {
- out.push({ kind: "literal", value: cur });
- }
- }
- return out;
-}
-
-function stripOuterQuotes(value: string): string {
- if (value.length >= 2) {
- const first = value[0];
- const last = value[value.length - 1];
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
- return value.slice(1, -1);
- }
- }
- return value;
-}
-
-function parsePromptSchema(rawSchema: string): PromptSchemaField[] {
- const trimmed = rawSchema.trim();
- if (trimmed.length === 0) return [];
- if (/[[\]|]/.test(trimmed)) {
- throw new Error("returns schema must be flat (no arrays or union types)");
- }
- const inner = trimmed.replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "").trim();
- if (inner.length === 0) return [];
- const fields: PromptSchemaField[] = [];
- for (const part of inner.split(",")) {
- const m = part.trim().match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(\S+)\s*$/);
- if (!m) {
- throw new Error(`invalid returns schema entry: ${part.trim().slice(0, 40)}`);
- }
- const [, name, typeStr] = m;
- const type = typeStr.toLowerCase();
- if (type !== "string" && type !== "number" && type !== "boolean") {
- throw new Error(`unsupported returns schema type: ${typeStr}`);
- }
- fields.push({ name, type: type as "string" | "number" | "boolean" });
- }
- return fields;
-}
-
export class NodeWorkflowRuntime {
private readonly env: NodeJS.ProcessEnv;
private readonly cwd: string;
@@ -328,13 +77,12 @@ export class NodeWorkflowRuntime {
private readonly runId: string;
private readonly runDir: string;
private readonly summaryFile: string;
+ private readonly emitter: RuntimeEventEmitter;
private heartbeatTimer: ReturnType | undefined;
- private stepSeq = 0;
private stack: Frame[] = [];
private asyncFrameStack = new AsyncLocalStorage ();
private asyncIndicesStorage = new AsyncLocalStorage();
private inboxSeq = 0;
- private promptSeq = 0;
private workflowCtxStack: WorkflowContext[] = [];
private readonly mockBodies: Map;
private handleRegistry = new Map();
@@ -397,7 +145,21 @@ export class NodeWorkflowRuntime {
return null;
}
- constructor(graph: RuntimeGraph, opts: { env?: NodeJS.ProcessEnv; cwd?: string; mockBodies?: Map }) {
+ constructor(
+ graph: RuntimeGraph,
+ opts: {
+ env?: NodeJS.ProcessEnv;
+ cwd?: string;
+ mockBodies?: Map;
+ /**
+ * When true, the runtime's event emitter skips writing `__JAIPH_EVENT__`
+ * lines to stderr (durable `run_summary.jsonl` writes are unaffected).
+ * Used by in-process callers like the test runner that share stderr
+ * with `node --test` reporter output.
+ */
+ suppressLiveEvents?: boolean;
+ },
+ ) {
this.graph = graph;
this.env = opts.env ?? process.env;
this.cwd = opts.cwd ?? process.cwd();
@@ -418,6 +180,14 @@ export class NodeWorkflowRuntime {
this.env.JAIPH_RUN_ID = this.runId;
this.env.JAIPH_RUN_DIR = this.runDir;
this.env.JAIPH_ARTIFACTS_DIR = artifactsDir;
+ this.emitter = new RuntimeEventEmitter({
+ runId: this.runId,
+ runDir: this.runDir,
+ env: this.env,
+ getFrameStack: () => this.getFrameStack(),
+ getAsyncIndices: () => this.getAsyncIndices(),
+ suppressLiveEvents: opts.suppressLiveEvents,
+ });
this.startHeartbeat();
}
@@ -451,7 +221,7 @@ export class NodeWorkflowRuntime {
}
async runDefault(args: string[]): Promise {
- this.emitWorkflow("WORKFLOW_START", "default");
+ this.emitter.emitWorkflow("WORKFLOW_START", "default");
const rootScope: Scope = {
filePath: this.graph.entryFile,
vars: this.newScopeVars(this.graph.entryFile, undefined, this.env),
@@ -463,7 +233,7 @@ export class NodeWorkflowRuntime {
});
if (!resolved) {
process.stderr.write("jaiph run requires workflow 'default' in the input file\n");
- this.emitWorkflow("WORKFLOW_END", "default");
+ this.emitter.emitWorkflow("WORKFLOW_END", "default");
this.stopHeartbeat();
return 1;
}
@@ -482,7 +252,7 @@ export class NodeWorkflowRuntime {
// Best-effort capture; the run succeeded regardless.
}
}
- this.emitWorkflow("WORKFLOW_END", "default");
+ this.emitter.emitWorkflow("WORKFLOW_END", "default");
this.stopHeartbeat();
return result.status;
}
@@ -519,139 +289,6 @@ export class NodeWorkflowRuntime {
return join(this.cwd, ".jaiph", "runs");
}
- private emitWorkflow(type: "WORKFLOW_START" | "WORKFLOW_END", workflow: string): void {
- appendRunSummaryLine(
- JSON.stringify({
- type,
- workflow,
- source: this.env.JAIPH_SOURCE_FILE ?? "",
- ts: nowIso(),
- run_id: this.runId,
- event_version: 1,
- }),
- );
- }
-
- private emitPromptEvent(
- type: "PROMPT_START" | "PROMPT_END",
- payload: { backend: string; model?: string; model_reason?: string; status?: number; preview?: string },
- ): void {
- const stack = this.getFrameStack();
- const current = stack.length > 0 ? stack[stack.length - 1] : null;
- appendRunSummaryLine(
- JSON.stringify({
- type,
- ts: nowIso(),
- run_id: this.runId,
- depth: stack.length,
- step_id: current?.id ?? null,
- step_name: current?.name ?? null,
- backend: payload.backend,
- model: payload.model ?? null,
- model_reason: payload.model_reason ?? null,
- status: payload.status ?? null,
- preview: payload.preview ?? null,
- event_version: 1,
- }),
- );
- }
-
- private emitPromptStepStart(
- backend: string,
- scopeVars: Map,
- rawPromptSource: string,
- ): PromptStepHandle {
- this.promptSeq += 1;
- this.stepSeq += 1;
- const stack = this.getFrameStack();
- const current = stack.length > 0 ? stack[stack.length - 1] : null;
- const id = `${this.runId}:${process.pid}:prompt:${this.promptSeq}`;
- const seq = this.stepSeq;
- const safe = sanitizeName("prompt__prompt");
- const outFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.out`);
- const errFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.err`);
- writeFileSync(outFile, "");
- writeFileSync(errFile, "");
- // Preview keeps the authored `${var}` placeholders rather than substituted values,
- // so the tree shows what the user wrote; concrete values live alongside in params.
- const preview = stripOuterQuotes(rawPromptSource).replace(/\s+/g, " ").trim();
- const params: Array<[string, string]> = [["prompt_text", preview]];
- const seen = new Set(["prompt_text"]);
- // Include named vars referenced in the prompt text.
- const refRe = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
- let m: RegExpExecArray | null;
- while ((m = refRe.exec(rawPromptSource)) !== null) {
- const name = m[1];
- if (!seen.has(name)) {
- seen.add(name);
- const val = scopeVars.get(name) ?? "";
- if (val.length > 0) params.push([name, val]);
- }
- }
- this.emitStep({
- type: "STEP_START",
- func: "prompt",
- kind: "prompt",
- name: backend,
- ts: nowIso(),
- status: null,
- elapsed_ms: null,
- out_file: outFile,
- err_file: errFile,
- id,
- parent_id: current?.id ?? null,
- seq,
- depth: stack.length,
- run_id: this.runId,
- params,
- });
- return { id, seq, outFile, errFile, backend, startedAtMs: Date.now() };
- }
-
- private emitPromptStepEnd(prompt: PromptStepHandle, status: number, outContent: string, errContent: string): void {
- const stack = this.getFrameStack();
- const current = stack.length > 0 ? stack[stack.length - 1] : null;
- if (errContent.length > 0) {
- writeFileSync(prompt.errFile, errContent);
- }
- this.emitStep({
- type: "STEP_END",
- func: "prompt",
- kind: "prompt",
- name: prompt.backend,
- ts: nowIso(),
- status,
- elapsed_ms: Date.now() - prompt.startedAtMs,
- out_file: prompt.outFile,
- err_file: prompt.errFile,
- id: prompt.id,
- parent_id: current?.id ?? null,
- seq: prompt.seq,
- depth: stack.length,
- run_id: this.runId,
- params: [],
- out_content: outContent.slice(0, MAX_EMBED),
- err_content: status !== 0 ? errContent.slice(0, MAX_EMBED) : "",
- });
- }
-
- private emitLog(type: "LOG" | "LOGERR", message: string): void {
- const depth = this.getFrameStack().length;
- const indices = this.getAsyncIndices();
- const liveBase: Record = { type, message, depth };
- if (indices.length > 0) liveBase.async_indices = indices;
- const payload = {
- ...liveBase,
- ts: nowIso(),
- run_id: this.runId,
- event_version: 1,
- };
- if (this.env.JAIPH_TEST_MODE !== "1") {
- process.stderr.write(`__JAIPH_EVENT__ ${JSON.stringify(liveBase)}\n`);
- }
- appendRunSummaryLine(JSON.stringify(payload));
- }
-
private async executeWorkflow(
filePath: string,
workflowName: string,
@@ -882,48 +519,29 @@ export class NodeWorkflowRuntime {
let asyncCounter = 0;
for (const step of steps) {
if (step.type === "comment" || step.type === "blank_line") continue;
- if (step.type === "log") {
+ if (step.type === "log" || step.type === "logerr") {
+ const level = step.type === "log" ? "LOG" : "LOGERR";
+ let message: string;
if (step.managed?.kind === "run_inline_script") {
const shebang = step.managed.lang ? `#!/usr/bin/env ${step.managed.lang}` : undefined;
const result = await this.executeInlineScript(scope, step.managed.body, shebang, step.managed.args ?? "");
if (result.status !== 0) return this.mergeStepResult(accOut, accErr, result);
- const message = result.returnValue ?? result.output.trim();
- this.emitLog("LOG", message);
- const chunk = `${message}\n`;
- accOut += chunk;
- io?.appendOut(chunk);
- continue;
+ message = result.returnValue ?? result.output.trim();
+ } else {
+ const raw = step.tripleQuoted ? plainMultilineOrchestrationForRuntime(step.message) : step.message;
+ const ir = await this.interpolateWithCaptures(raw, scope);
+ if (!ir.ok) return this.mergeStepResult(accOut, accErr, ir.result);
+ message = ir.value;
}
- const logMsg = step.tripleQuoted ? plainMultilineOrchestrationForRuntime(step.message) : step.message;
- const logIr = await this.interpolateWithCaptures(logMsg, scope);
- if (!logIr.ok) return this.mergeStepResult(accOut, accErr, logIr.result);
- const message = logIr.value;
- this.emitLog("LOG", message);
+ this.emitter.emitLog(level, message);
const chunk = `${message}\n`;
- accOut += chunk;
- io?.appendOut(chunk);
- continue;
- }
- if (step.type === "logerr") {
- if (step.managed?.kind === "run_inline_script") {
- const shebang = step.managed.lang ? `#!/usr/bin/env ${step.managed.lang}` : undefined;
- const result = await this.executeInlineScript(scope, step.managed.body, shebang, step.managed.args ?? "");
- if (result.status !== 0) return this.mergeStepResult(accOut, accErr, result);
- const message = result.returnValue ?? result.output.trim();
- this.emitLog("LOGERR", message);
- const chunk = `${message}\n`;
+ if (level === "LOG") {
+ accOut += chunk;
+ io?.appendOut(chunk);
+ } else {
accErr += chunk;
io?.appendErr(chunk);
- continue;
}
- const logerrMsg = step.tripleQuoted ? plainMultilineOrchestrationForRuntime(step.message) : step.message;
- const logErrIr = await this.interpolateWithCaptures(logerrMsg, scope);
- if (!logErrIr.ok) return this.mergeStepResult(accOut, accErr, logErrIr.result);
- const message = logErrIr.value;
- this.emitLog("LOGERR", message);
- const chunk = `${message}\n`;
- accErr += chunk;
- io?.appendErr(chunk);
continue;
}
if (step.type === "fail") {
@@ -934,11 +552,17 @@ export class NodeWorkflowRuntime {
return this.mergeStepResult(accOut, accErr, { status: 1, output: "", error: message });
}
if (step.type === "shell") {
- return this.mergeStepResult(accOut, accErr, {
- status: 1,
- output: "",
- error: "inline shell steps are forbidden in Node orchestration runtime; use script blocks",
- });
+ const cmdIr = await this.interpolateWithCaptures(step.command, scope);
+ if (!cmdIr.ok) return this.mergeStepResult(accOut, accErr, cmdIr.result);
+ const stepName = `sh_line_${step.loc.line}`;
+ const result = await this.executeManagedStep(
+ "script",
+ stepName,
+ [],
+ (io) => this.executeShLine(scope, cmdIr.value, io),
+ );
+ if (result.status !== 0) return this.mergeStepResult(accOut, accErr, result);
+ continue;
}
if (step.type === "return") {
if (step.managed) {
@@ -1011,17 +635,22 @@ export class NodeWorkflowRuntime {
};
// Route to the nearest ancestor context that has a route for this channel.
let targetCtx = ctx;
+ let routed = false;
for (let i = this.workflowCtxStack.length - 1; i >= 0; i -= 1) {
if (this.workflowCtxStack[i]!.routes.has(step.channel)) {
targetCtx = this.workflowCtxStack[i]!;
+ routed = true;
break;
}
}
targetCtx.queue.push(msg);
- // Persist inbox file to run directory.
- const inboxFileDir = join(this.runDir, "inbox");
- mkdirSync(inboxFileDir, { recursive: true });
- writeFileSync(join(inboxFileDir, `${seqPadded}-${step.channel}.txt`), payload, "utf8");
+ // Persist inbox file only when a route consumes the channel — otherwise
+ // the file would be dead audit data with no corresponding dispatch.
+ if (routed) {
+ const inboxFileDir = join(this.runDir, "inbox");
+ mkdirSync(inboxFileDir, { recursive: true });
+ writeFileSync(join(inboxFileDir, `${seqPadded}-${step.channel}.txt`), payload, "utf8");
+ }
appendRunSummaryLine(
JSON.stringify({
type: "INBOX_ENQUEUE",
@@ -1036,89 +665,16 @@ export class NodeWorkflowRuntime {
continue;
}
if (step.type === "prompt") {
- const promptRaw =
- step.bodyKind === "triple_quoted" ? tripleQuotedRawForRuntime(step.raw) : step.raw;
- const promptIr = await this.interpolateWithCaptures(promptRaw, scope);
- if (!promptIr.ok) return this.mergeStepResult(accOut, accErr, promptIr.result);
- let promptText = promptIr.value;
- const promptConfig = resolveConfig(scope.env);
- const backend = promptConfig.backend || "cursor";
- const stepName = resolvePromptStepName(promptConfig);
- const modelRes = resolveModel(promptConfig);
- const promptStep = this.emitPromptStepStart(stepName, scope.vars, step.raw);
- this.emitPromptEvent("PROMPT_START", {
- backend,
- model: modelRes.model || undefined,
- model_reason: modelRes.reason,
- preview: promptText.slice(0, 120),
- });
- let schemaFields: PromptSchemaField[] | undefined;
- if (step.returns !== undefined) {
- schemaFields = parsePromptSchema(step.returns);
- const schemaObject = Object.fromEntries(schemaFields.map((f) => [f.name, f.type]));
- promptText +=
- "\n\nRespond with exactly one line of valid JSON (no markdown, no explanation) matching this schema: " +
- JSON.stringify(schemaObject);
- }
- const out = new PassThrough();
- const chunks: string[] = [];
- const err = new PassThrough();
- const errChunks: string[] = [];
- out.on("data", (d) => {
- const chunk = String(d);
- chunks.push(chunk);
- appendFileSync(promptStep.outFile, chunk);
- io?.appendOut(chunk);
- });
- err.on("data", (d) => {
- const chunk = String(d);
- errChunks.push(chunk);
- io?.appendErr(chunk);
- });
- const result = await executePrompt(promptText, promptConfig, out, scope.env, err);
- const promptErr = errChunks.join("");
- this.emitPromptStepEnd(promptStep, result.status, chunks.join(""), promptErr);
- this.emitPromptEvent("PROMPT_END", { backend, model: modelRes.model || undefined, model_reason: modelRes.reason, status: result.status });
- const output = chunks.join("");
- accOut += output;
- if (result.status !== 0) {
+ if (step.returns !== undefined && !step.captureName) {
return this.mergeStepResult(accOut, accErr, {
- status: result.status,
+ status: 1,
output: "",
- error: promptErr.trim() || "prompt failed",
+ error: 'prompt with "returns" schema must capture to a variable',
});
}
- if (schemaFields) {
- if (!step.captureName) {
- return this.mergeStepResult(accOut, accErr, {
- status: 1,
- output: "",
- error: 'prompt with "returns" schema must capture to a variable',
- });
- }
- const extracted = extractJson(result.final);
- if (!extracted) {
- return this.mergeStepResult(accOut, accErr, {
- status: 1,
- output: "",
- error: "prompt returned invalid JSON",
- });
- }
- const validation = validateFields(extracted.obj, schemaFields);
- if (validation !== 0) {
- return this.mergeStepResult(accOut, accErr, {
- status: validation,
- output: "",
- error: "prompt response failed schema validation",
- });
- }
- scope.vars.set(step.captureName, extracted.source);
- for (const field of schemaFields) {
- scope.vars.set(`${step.captureName}_${field.name}`, String(extracted.obj[field.name]));
- }
- } else if (step.captureName) {
- scope.vars.set(step.captureName, result.final);
- }
+ const r = await this.runPromptStep(scope, step.raw, step.bodyKind, step.returns, step.captureName, io);
+ accOut += r.output;
+ if (!r.ok) return this.mergeStepResult(accOut, accErr, r.result);
continue;
}
if (step.type === "const") {
@@ -1173,85 +729,16 @@ export class NodeWorkflowRuntime {
continue;
}
if (step.value.kind === "prompt_capture") {
- const pcRaw =
- step.value.bodyKind === "triple_quoted"
- ? tripleQuotedRawForRuntime(step.value.raw)
- : step.value.raw;
- const pcIr = await this.interpolateWithCaptures(pcRaw, scope);
- if (!pcIr.ok) return this.mergeStepResult(accOut, accErr, pcIr.result);
- let promptText = pcIr.value;
- const promptConfig = resolveConfig(scope.env);
- const backend = promptConfig.backend || "cursor";
- const stepName = resolvePromptStepName(promptConfig);
- const modelRes = resolveModel(promptConfig);
- const promptStep = this.emitPromptStepStart(
- stepName,
- scope.vars,
+ const r = await this.runPromptStep(
+ scope,
step.value.raw,
+ step.value.bodyKind,
+ step.value.returns,
+ step.name,
+ io,
);
- this.emitPromptEvent("PROMPT_START", {
- backend,
- model: modelRes.model || undefined,
- model_reason: modelRes.reason,
- preview: promptText.slice(0, 120),
- });
- let schemaFields: PromptSchemaField[] | undefined;
- if (step.value.returns !== undefined) {
- schemaFields = parsePromptSchema(step.value.returns);
- const schemaObject = Object.fromEntries(schemaFields.map((f) => [f.name, f.type]));
- promptText +=
- "\n\nRespond with exactly one line of valid JSON (no markdown, no explanation) matching this schema: " +
- JSON.stringify(schemaObject);
- }
- const out = new PassThrough();
- const chunks: string[] = [];
- const err = new PassThrough();
- const errChunks: string[] = [];
- out.on("data", (d) => {
- const chunk = String(d);
- chunks.push(chunk);
- appendFileSync(promptStep.outFile, chunk);
- io?.appendOut(chunk);
- });
- err.on("data", (d) => {
- const chunk = String(d);
- errChunks.push(chunk);
- io?.appendErr(chunk);
- });
- const result = await executePrompt(promptText, promptConfig, out, scope.env, err);
- const promptErr = errChunks.join("");
- this.emitPromptStepEnd(promptStep, result.status, chunks.join(""), promptErr);
- this.emitPromptEvent("PROMPT_END", { backend, model: modelRes.model || undefined, model_reason: modelRes.reason, status: result.status });
- const pcOut = chunks.join("");
- accOut += pcOut;
- if (result.status !== 0) {
- return this.mergeStepResult(accOut, accErr, {
- status: result.status,
- output: "",
- error: promptErr.trim() || "prompt failed",
- });
- }
- if (schemaFields) {
- const extracted = extractJson(result.final);
- if (!extracted) {
- return this.mergeStepResult(accOut, accErr, {
- status: 1,
- output: "",
- error: "prompt returned invalid JSON",
- });
- }
- const validation = validateFields(extracted.obj, schemaFields);
- if (validation !== 0) {
- return this.mergeStepResult(accOut, accErr, {
- status: validation,
- output: "",
- error: "prompt response failed schema validation",
- });
- }
- scope.vars.set(step.name, extracted.source);
- } else {
- scope.vars.set(step.name, result.final);
- }
+ accOut += r.output;
+ if (!r.ok) return this.mergeStepResult(accOut, accErr, r.result);
continue;
}
}
@@ -1260,65 +747,53 @@ export class NodeWorkflowRuntime {
asyncCounter += 1;
const branchStack = [...this.getFrameStack()];
const branchIndices = [...this.getAsyncIndices(), asyncCounter];
+ const ref = step.workflow.value;
+ const argsRaw = step.args ?? "";
+ const runInBranch = (fn: () => Promise): Promise =>
+ this.asyncFrameStack.run(branchStack, () =>
+ this.asyncIndicesStorage.run(branchIndices, fn),
+ );
let promise: Promise;
- if (step.recoverLoop) {
+ if (step.recover) {
// Async + recover loop: wrap retry logic in a single promise.
const recoverLimit = this.resolveRecoverLimit(scope.filePath);
- const loopSteps = "single" in step.recoverLoop ? [step.recoverLoop.single] : step.recoverLoop.block;
- const recoverBindings = step.recoverLoop.bindings;
- promise = this.asyncFrameStack.run(branchStack, () =>
- this.asyncIndicesStorage.run(branchIndices, async () => {
- let lastResult = await this.executeRunRef(scope, step.workflow.value, step.args ?? "");
- let attempt = 1;
- while (lastResult.status !== 0 && attempt <= recoverLimit) {
- const loopVars = new Map(scope.vars);
- loopVars.set(recoverBindings.failure, `${lastResult.output}${lastResult.error}`);
- const rr = await this.executeSteps({ ...scope, vars: loopVars }, loopSteps);
- if (rr.status !== 0 || rr.returnValue !== undefined) return rr;
- lastResult = await this.executeRunRef(scope, step.workflow.value, step.args ?? "");
- attempt += 1;
- }
- return lastResult;
- }),
- );
- } else if (step.recover) {
- // Async + catch: single-shot recovery in the async branch.
- const recoverSteps = "single" in step.recover ? [step.recover.single] : step.recover.block;
- const recoverBindings = step.recover.bindings;
- promise = this.asyncFrameStack.run(branchStack, () =>
- this.asyncIndicesStorage.run(branchIndices, async () => {
- const result = await this.executeRunRef(scope, step.workflow.value, step.args ?? "");
- if (result.status === 0) return result;
- const recoverVars = new Map(scope.vars);
- recoverVars.set(recoverBindings.failure, `${result.output}${result.error}`);
- const rr = await this.executeSteps({ ...scope, vars: recoverVars }, recoverSteps);
+ const recover = step.recover;
+ promise = runInBranch(async () => {
+ let lastResult = await this.executeRunRef(scope, ref, argsRaw);
+ let attempt = 1;
+ while (lastResult.status !== 0 && attempt <= recoverLimit) {
+ const rr = await this.runRecoverBody(scope, recover, `${lastResult.output}${lastResult.error}`);
if (rr.status !== 0 || rr.returnValue !== undefined) return rr;
- return { status: 0, output: result.output, error: result.error };
- }),
- );
+ lastResult = await this.executeRunRef(scope, ref, argsRaw);
+ attempt += 1;
+ }
+ return lastResult;
+ });
+ } else if (step.catch) {
+ // Async + catch: single-shot recovery in the async branch.
+ const recover = step.catch;
+ promise = runInBranch(async () => {
+ const result = await this.executeRunRef(scope, ref, argsRaw);
+ if (result.status === 0) return result;
+ const rr = await this.runRecoverBody(scope, recover, `${result.output}${result.error}`);
+ if (rr.status !== 0) return rr;
+ if (rr.returnValue !== undefined) return { ...rr, recoverReturn: true };
+ return { status: 0, output: result.output, error: result.error };
+ });
} else {
- promise = this.asyncFrameStack.run(branchStack, () =>
- this.asyncIndicesStorage.run(branchIndices, () =>
- this.executeRunRef(scope, step.workflow.value, step.args ?? ""),
- ),
- );
+ promise = runInBranch(() => this.executeRunRef(scope, ref, argsRaw));
}
- const handleId = this.createHandle(step.workflow.value, promise);
+ const handleId = this.createHandle(ref, promise);
localHandleIds.push(handleId);
- if (step.captureName) {
- scope.vars.set(step.captureName, handleId);
- }
+ if (step.captureName) scope.vars.set(step.captureName, handleId);
continue;
}
- if (step.recoverLoop) {
+ if (step.recover) {
const limit = this.resolveRecoverLimit(scope.filePath);
- const loopSteps = "single" in step.recoverLoop ? [step.recoverLoop.single] : step.recoverLoop.block;
let lastResult = await this.executeRunRef(scope, step.workflow.value, step.args ?? "");
let attempt = 1;
while (lastResult.status !== 0 && attempt <= limit) {
- const loopVars = new Map(scope.vars);
- loopVars.set(step.recoverLoop.bindings.failure, `${lastResult.output}${lastResult.error}`);
- const rr = await this.executeSteps({ ...scope, vars: loopVars }, loopSteps);
+ const rr = await this.runRecoverBody(scope, step.recover, `${lastResult.output}${lastResult.error}`);
if (rr.status !== 0 || rr.returnValue !== undefined) return this.mergeStepResult(accOut, accErr, rr);
lastResult = await this.executeRunRef(scope, step.workflow.value, step.args ?? "");
attempt += 1;
@@ -1337,12 +812,8 @@ export class NodeWorkflowRuntime {
if (step.captureName) {
scope.vars.set(step.captureName, runResult.returnValue ?? runResult.output.trim());
}
- } else if (step.recover) {
- const recoverSteps = "single" in step.recover ? [step.recover.single] : step.recover.block;
- const recoverVars = new Map(scope.vars);
- const recoverPayload = `${runResult.output}${runResult.error}`;
- recoverVars.set(step.recover.bindings.failure, recoverPayload);
- const rr = await this.executeSteps({ ...scope, vars: recoverVars }, recoverSteps);
+ } else if (step.catch) {
+ const rr = await this.runRecoverBody(scope, step.catch, `${runResult.output}${runResult.error}`);
if (rr.status !== 0 || rr.returnValue !== undefined) return this.mergeStepResult(accOut, accErr, rr);
} else {
return this.mergeStepResult(accOut, accErr, runResult);
@@ -1359,7 +830,7 @@ export class NodeWorkflowRuntime {
continue;
}
if (step.type === "ensure") {
- const ensureResult = await this.executeEnsureRef(scope, step.ref.value, step.args ?? "", step.recover);
+ const ensureResult = await this.executeEnsureRef(scope, step.ref.value, step.args ?? "", step.catch);
if (step.captureName && ensureResult.status === 0) {
scope.vars.set(step.captureName, ensureResult.returnValue ?? ensureResult.output.trim());
}
@@ -1405,29 +876,30 @@ export class NodeWorkflowRuntime {
// Implicit join: await all unresolved handles created in this scope before returning.
if (localHandleIds.length > 0) {
const failures: string[] = [];
+ const collectResult = (handleRef: string, result: StepResult): void => {
+ if (result.status !== 0) {
+ failures.push(`run async ${handleRef}: ${result.error}`);
+ accOut += result.output;
+ accErr += result.error;
+ } else {
+ accOut += result.output;
+ // An async branch that recovered via `return X` propagates that value
+ // to the parent workflow, mirroring sync ensure/run+catch semantics.
+ if (result.recoverReturn && result.returnValue !== undefined && returnValue === undefined) {
+ returnValue = result.returnValue;
+ }
+ }
+ };
for (const handleId of localHandleIds) {
const handle = this.handleRegistry.get(handleId);
if (!handle) continue;
if (handle.resolved) {
- // Already resolved (via a read earlier) — just check status.
- if (handle.resolved.status !== 0) {
- failures.push(`run async ${handle.ref}: ${handle.resolved.error}`);
- accOut += handle.resolved.output;
- accErr += handle.resolved.error;
- } else {
- accOut += handle.resolved.output;
- }
+ collectResult(handle.ref, handle.resolved);
continue;
}
try {
const result = await this.resolveHandleResult(handleId);
- if (result.status !== 0) {
- failures.push(`run async ${handle.ref}: ${result.error}`);
- accOut += result.output;
- accErr += result.error;
- } else {
- accOut += result.output;
- }
+ collectResult(handle.ref, result);
} catch (err) {
failures.push(`run async ${handle.ref}: ${String(err)}`);
}
@@ -1455,92 +927,47 @@ export class NodeWorkflowRuntime {
}
private async drainWorkflowQueue(scope: Scope, ctx: WorkflowContext): Promise {
- const parallel = scope.env.JAIPH_INBOX_PARALLEL === "true";
let cursor = 0;
while (cursor < ctx.queue.length) {
const msg = ctx.queue[cursor]!;
cursor += 1;
const targets = ctx.routes.get(msg.channel) ?? [];
if (targets.length === 0) continue;
- if (parallel) {
- const inboxArgs = [msg.content, msg.channel, msg.sender];
- const dispatches = await Promise.all(
- targets.map(async (target) => {
- appendRunSummaryLine(
- JSON.stringify({
- type: "INBOX_DISPATCH_START",
- ts: nowIso(),
- run_id: this.runId,
- channel: msg.channel,
- sender: msg.sender,
- inbox_seq: msg.seqPadded,
- target,
- event_version: 1,
- }),
- );
- const t0 = Date.now();
- const result = await this.executeRunRef(
- this.buildInboxDispatchScope(scope, target, msg),
- target,
- inboxArgs,
- );
- appendRunSummaryLine(
- JSON.stringify({
- type: "INBOX_DISPATCH_COMPLETE",
- ts: nowIso(),
- run_id: this.runId,
- channel: msg.channel,
- sender: msg.sender,
- inbox_seq: msg.seqPadded,
- target,
- status: result.status,
- elapsed_ms: Date.now() - t0,
- event_version: 1,
- }),
- );
- return result;
+ const inboxArgs = [msg.content, msg.channel, msg.sender];
+ for (const target of targets) {
+ appendRunSummaryLine(
+ JSON.stringify({
+ type: "INBOX_DISPATCH_START",
+ ts: nowIso(),
+ run_id: this.runId,
+ channel: msg.channel,
+ sender: msg.sender,
+ inbox_seq: msg.seqPadded,
+ target,
+ event_version: 1,
}),
);
- for (const d of dispatches) {
- if (d.status !== 0) return d;
- }
- } else {
- const inboxArgs = [msg.content, msg.channel, msg.sender];
- for (const target of targets) {
- appendRunSummaryLine(
- JSON.stringify({
- type: "INBOX_DISPATCH_START",
- ts: nowIso(),
- run_id: this.runId,
- channel: msg.channel,
- sender: msg.sender,
- inbox_seq: msg.seqPadded,
- target,
- event_version: 1,
- }),
- );
- const t0 = Date.now();
- const dispatch = await this.executeRunRef(
- this.buildInboxDispatchScope(scope, target, msg),
+ const t0 = Date.now();
+ const dispatch = await this.executeRunRef(
+ this.buildInboxDispatchScope(scope, target, msg),
+ target,
+ inboxArgs,
+ );
+ appendRunSummaryLine(
+ JSON.stringify({
+ type: "INBOX_DISPATCH_COMPLETE",
+ ts: nowIso(),
+ run_id: this.runId,
+ channel: msg.channel,
+ sender: msg.sender,
+ inbox_seq: msg.seqPadded,
target,
- inboxArgs,
- );
- appendRunSummaryLine(
- JSON.stringify({
- type: "INBOX_DISPATCH_COMPLETE",
- ts: nowIso(),
- run_id: this.runId,
- channel: msg.channel,
- sender: msg.sender,
- inbox_seq: msg.seqPadded,
- target,
- status: dispatch.status,
- elapsed_ms: Date.now() - t0,
- event_version: 1,
- }),
- );
- if (dispatch.status !== 0) return dispatch;
- }
+ status: dispatch.status,
+ elapsed_ms: Date.now() - t0,
+ event_version: 1,
+ }),
+ );
+ if (dispatch.status !== 0) return dispatch;
}
}
return { status: 0, output: "", error: "" };
@@ -1550,21 +977,26 @@ export class NodeWorkflowRuntime {
return `${filePath}::${name}`;
}
- /** Synchronous fast-path: resolve args when every token is a plain literal and no handles. */
- private resolveArgsRawSync(scope: Scope, raw: string | string[]): string[] | null {
- if (Array.isArray(raw)) return raw;
- const tokens = parseArgTokens(raw);
- for (const token of tokens) {
- if (token.kind !== "literal") return null;
- // Bail to async path if any referenced var is a handle.
- const varRe = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)/g;
- let vm: RegExpExecArray | null;
- while ((vm = varRe.exec(token.value)) !== null) {
- const val = scope.vars.get(vm[1]);
- if (val && this.isHandle(val)) return null;
- }
- }
- return tokens.map((t) => interpolate((t as { kind: "literal"; value: string }).value, scope.vars, scope.env));
+ private dispatchMockBody(ref: string, mockDef: MockBodyDef, args: string[]): Promise {
+ return executeMockBodyDef({
+ ref,
+ mockDef,
+ args,
+ env: this.env,
+ cwd: this.cwd,
+ executeStepsBack: (params, stepArgs, steps) => {
+ const scope: Scope = {
+ filePath: this.graph.entryFile,
+ vars: new Map(),
+ env: { ...this.env },
+ declaredParamNames: params,
+ };
+ params.forEach((name, i) => {
+ if (i < stepArgs.length) scope.vars.set(name, stepArgs[i]);
+ });
+ return this.executeSteps(scope, steps);
+ },
+ });
}
private async resolveArgsRaw(scope: Scope, raw: string | string[]): Promise {
@@ -1599,7 +1031,7 @@ export class NodeWorkflowRuntime {
}
private async executeRunRef(scope: Scope, ref: string, argsRaw: string | string[]): Promise {
- const resolvedArgs = this.resolveArgsRawSync(scope, argsRaw) ?? await this.resolveArgsRaw(scope, argsRaw);
+ const resolvedArgs = await this.resolveArgsRaw(scope, argsRaw);
if (!Array.isArray(resolvedArgs)) return resolvedArgs;
const args = resolvedArgs;
const resolvedWorkflow = resolveWorkflowRef(this.graph, scope.filePath, { value: ref, loc: { line: 1, col: 1 } });
@@ -1611,7 +1043,7 @@ export class NodeWorkflowRuntime {
"workflow",
ref,
args,
- async () => this.executeMockBodyDef(ref, mockBody, args),
+ async () => this.dispatchMockBody(ref, mockBody, args),
resolvedWorkflow.workflow.params,
);
}
@@ -1622,7 +1054,7 @@ export class NodeWorkflowRuntime {
const mk = this.mockKey(resolvedScript.filePath, resolvedScript.script.name);
const mockBody = this.mockBodies.get(mk);
if (mockBody !== undefined) {
- return this.executeManagedStep("script", ref, args, async () => this.executeMockBodyDef(ref, mockBody, args));
+ return this.executeManagedStep("script", ref, args, async () => this.dispatchMockBody(ref, mockBody, args));
}
return this.executeManagedStep(
"script",
@@ -1634,11 +1066,119 @@ export class NodeWorkflowRuntime {
return { status: 1, output: "", error: `Unknown run target: ${ref}` };
}
+ /**
+ * Execute a prompt step, stream output to artifacts, and bind the captured
+ * value (and per-field exports when a returns schema is set) into `scope`.
+ * Returns the chunk of stdout to add to the caller's accumulator.
+ */
+ private async runPromptStep(
+ scope: Scope,
+ raw: string,
+ bodyKind: "string" | "identifier" | "triple_quoted" | undefined,
+ returns: string | undefined,
+ captureName: string | undefined,
+ io: StepIO | undefined,
+ ): Promise<{ ok: true; output: string } | { ok: false; result: StepResult; output: string }> {
+ const promptRaw = bodyKind === "triple_quoted" ? tripleQuotedRawForRuntime(raw) : raw;
+ const promptIr = await this.interpolateWithCaptures(promptRaw, scope);
+ if (!promptIr.ok) return { ok: false, result: promptIr.result, output: "" };
+ let promptText = promptIr.value;
+ const promptConfig = resolveConfig(scope.env);
+ const backend = promptConfig.backend || "cursor";
+ const stepName = resolvePromptStepName(promptConfig);
+ const modelRes = resolveModel(promptConfig);
+ const promptStep = this.emitter.emitPromptStepStart(stepName, scope.vars, raw);
+ this.emitter.emitPromptEvent("PROMPT_START", {
+ backend,
+ model: modelRes.model || undefined,
+ model_reason: modelRes.reason,
+ preview: promptText.slice(0, 120),
+ });
+ let schemaFields: PromptSchemaField[] | undefined;
+ if (returns !== undefined) {
+ schemaFields = parsePromptSchema(returns);
+ const schemaObject = Object.fromEntries(schemaFields.map((f) => [f.name, f.type]));
+ promptText +=
+ "\n\nRespond with exactly one line of valid JSON (no markdown, no explanation) matching this schema: " +
+ JSON.stringify(schemaObject);
+ }
+ const out = new PassThrough();
+ const chunks: string[] = [];
+ const err = new PassThrough();
+ const errChunks: string[] = [];
+ out.on("data", (d) => {
+ const chunk = String(d);
+ chunks.push(chunk);
+ appendFileSync(promptStep.outFile, chunk);
+ io?.appendOut(chunk);
+ });
+ err.on("data", (d) => {
+ const chunk = String(d);
+ errChunks.push(chunk);
+ io?.appendErr(chunk);
+ });
+ const result = await executePrompt(promptText, promptConfig, out, scope.env, err);
+ const promptErr = errChunks.join("");
+ this.emitter.emitPromptStepEnd(promptStep, result.status, chunks.join(""), promptErr);
+ this.emitter.emitPromptEvent("PROMPT_END", {
+ backend,
+ model: modelRes.model || undefined,
+ model_reason: modelRes.reason,
+ status: result.status,
+ });
+ const output = chunks.join("");
+ if (result.status !== 0) {
+ return {
+ ok: false,
+ result: { status: result.status, output: "", error: promptErr.trim() || "prompt failed" },
+ output,
+ };
+ }
+ if (schemaFields) {
+ const extracted = extractJson(result.final);
+ if (!extracted) {
+ return { ok: false, result: { status: 1, output: "", error: "prompt returned invalid JSON" }, output };
+ }
+ const validation = validateFields(extracted.obj, schemaFields);
+ if (validation !== 0) {
+ return {
+ ok: false,
+ result: { status: validation, output: "", error: "prompt response failed schema validation" },
+ output,
+ };
+ }
+ if (captureName) {
+ scope.vars.set(captureName, extracted.source);
+ for (const field of schemaFields) {
+ scope.vars.set(`${captureName}_${field.name}`, String(extracted.obj[field.name]));
+ }
+ }
+ } else if (captureName) {
+ scope.vars.set(captureName, result.final);
+ }
+ return { ok: true, output };
+ }
+
+ /** Run a recover/catch body with `failure` bound to the failed step's payload. */
+ private async runRecoverBody(
+ scope: Scope,
+ catchDef: { bindings: { failure: string } } & (
+ | { single: WorkflowStepDef }
+ | { block: WorkflowStepDef[] }
+ ),
+ failurePayload: string,
+ ): Promise {
+ const recoverSteps = "single" in catchDef ? [catchDef.single] : catchDef.block;
+ const recoverVars = new Map(scope.vars);
+ recoverVars.set(catchDef.bindings.failure, failurePayload);
+ return this.executeSteps({ ...scope, vars: recoverVars }, recoverSteps);
+ }
+
private async executeEnsureRef(
scope: Scope,
ref: string,
argsRaw: string,
- recover: EnsureRecover | undefined,
+ catchDef: EnsureRecover | undefined,
): Promise {
const resolvedArgs = await this.resolveArgsRaw(scope, argsRaw);
if (!Array.isArray(resolvedArgs)) return resolvedArgs;
@@ -1653,7 +1193,7 @@ export class NodeWorkflowRuntime {
"rule",
ref,
args,
- async () => this.executeMockBodyDef(ref, mockBody, args),
+ async () => this.dispatchMockBody(ref, mockBody, args),
resolvedRule.rule.params,
);
}
@@ -1661,37 +1201,23 @@ export class NodeWorkflowRuntime {
};
const res = await attempt();
if (res.status === 0) return res;
- if (!recover) return res;
- const recoverSteps = "single" in recover ? [recover.single] : recover.block;
- const recoverVars = new Map(scope.vars);
- const recoverPayload = `${res.output}${res.error}`;
- recoverVars.set(recover.bindings.failure, recoverPayload);
- const rr = await this.executeSteps({ ...scope, vars: recoverVars }, recoverSteps);
+ if (!catchDef) return res;
+ const rr = await this.runRecoverBody(scope, catchDef, `${res.output}${res.error}`);
if (rr.status !== 0) return rr;
if (rr.returnValue !== undefined) return { ...rr, recoverReturn: true };
return { status: 0, output: res.output, error: "" };
}
- private async executeScript(
- filePath: string,
- scriptName: string,
+ /** Spawn a child process, stream stdout/stderr into io and collect them into the StepResult. */
+ private spawnAndCapture(
+ command: string,
args: string[],
env: NodeJS.ProcessEnv,
- io?: StepIO,
+ cwd: string,
+ io: StepIO | undefined,
): Promise {
- const scriptsDir = env.JAIPH_SCRIPTS;
- if (!scriptsDir) {
- return { status: 1, output: "", error: "JAIPH_SCRIPTS not set for script execution" };
- }
- const scriptPath = join(scriptsDir, scriptName);
- const scriptCwd =
- env.JAIPH_WORKSPACE && env.JAIPH_WORKSPACE.length > 0 ? env.JAIPH_WORKSPACE : dirname(filePath);
- return await new Promise((resolve) => {
- const child = spawn(scriptPath, args, {
- cwd: scriptCwd,
- env,
- stdio: ["ignore", "pipe", "pipe"],
- });
+ return new Promise((resolve) => {
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let error = "";
child.stdout?.setEncoding("utf8");
@@ -1708,24 +1234,48 @@ export class NodeWorkflowRuntime {
const msg = err instanceof Error ? err.message : String(err);
error += msg;
io?.appendErr(msg);
- resolve({
- status: 1,
- output,
- error,
- returnValue: output.trim(),
- });
+ resolve({ status: 1, output, error });
});
child.on("close", (code) => {
+ const status = typeof code === "number" ? code : 1;
resolve({
- status: typeof code === "number" ? code : 1,
+ status,
output,
error,
- returnValue: output.trim(),
+ ...(status === 0 ? { returnValue: output.trim() } : {}),
});
});
});
}
+ private scriptCwd(env: NodeJS.ProcessEnv, fallbackFilePath: string): string {
+ return env.JAIPH_WORKSPACE && env.JAIPH_WORKSPACE.length > 0
+ ? env.JAIPH_WORKSPACE
+ : dirname(fallbackFilePath);
+ }
+
+ private async executeScript(
+ filePath: string,
+ scriptName: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+ io?: StepIO,
+ ): Promise {
+ const scriptsDir = env.JAIPH_SCRIPTS;
+ if (!scriptsDir) {
+ return { status: 1, output: "", error: "JAIPH_SCRIPTS not set for script execution" };
+ }
+ return this.spawnAndCapture(join(scriptsDir, scriptName), args, env, this.scriptCwd(env, filePath), io);
+ }
+
+ /**
+ * Run a raw workflow shell line (after Jaiph interpolation) via `sh -c` in
+ * the workspace, matching script cwd semantics.
+ */
+ private executeShLine(scope: Scope, command: string, io: StepIO): Promise {
+ return this.spawnAndCapture("sh", ["-c", command], scope.env, this.scriptCwd(scope.env, scope.filePath), io);
+ }
+
private async executeInlineScript(
scope: Scope,
body: string,
@@ -1789,9 +1339,6 @@ export class NodeWorkflowRuntime {
if (parentEnv.JAIPH_DEBUG_LOCKED !== "1" && meta.run?.debug !== undefined) {
nextEnv.JAIPH_DEBUG = meta.run.debug ? "true" : "false";
}
- if (parentEnv.JAIPH_INBOX_PARALLEL_LOCKED !== "1" && meta.run?.inboxParallel !== undefined) {
- nextEnv.JAIPH_INBOX_PARALLEL = meta.run.inboxParallel ? "true" : "false";
- }
};
apply(moduleMeta);
apply(workflowMeta);
@@ -1810,8 +1357,7 @@ export class NodeWorkflowRuntime {
fn: (io: StepIO) => Promise,
declaredParamNames?: string[],
): Promise {
- this.stepSeq += 1;
- const seq = this.stepSeq;
+ const seq = this.emitter.allocStepSeq();
const safe = sanitizeName(`${kind}__${name}`);
const outFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.out`);
const errFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.err`);
@@ -1834,7 +1380,7 @@ export class NodeWorkflowRuntime {
if (chunk.length > 0) appendFileSync(errFile, chunk);
},
};
- this.emitStep({
+ this.emitter.emitStep({
type: "STEP_START",
func: name,
kind,
@@ -1856,7 +1402,7 @@ export class NodeWorkflowRuntime {
const elapsed = Date.now() - started;
writeFileSync(outFile, result.output ?? "");
writeFileSync(errFile, result.error ?? "");
- this.emitStep({
+ this.emitter.emitStep({
type: "STEP_END",
func: name,
kind,
@@ -1878,57 +1424,4 @@ export class NodeWorkflowRuntime {
stack.pop();
return result;
}
-
- private emitStep(payload: Record): void {
- const indices = this.getAsyncIndices();
- const full = indices.length > 0 ? { ...payload, async_indices: indices } : payload;
- if (this.env.JAIPH_TEST_MODE !== "1") {
- process.stderr.write(`__JAIPH_EVENT__ ${JSON.stringify(full)}\n`);
- }
- appendRunSummaryLine(JSON.stringify({ ...full, event_version: 1 }));
- }
-
- private async executeMockBodyDef(ref: string, mockDef: MockBodyDef, args: string[]): Promise {
- if (mockDef.kind === "shell") {
- return this.executeMockShellBody(ref, mockDef.body, args, mockDef.params);
- }
- // Jaiph step-based mock (workflow/rule)
- const scope: Scope = {
- filePath: this.graph.entryFile,
- vars: new Map(),
- env: { ...this.env },
- declaredParamNames: mockDef.params,
- };
- mockDef.params.forEach((name, i) => {
- if (i < args.length) scope.vars.set(name, args[i]);
- });
- return this.executeSteps(scope, mockDef.steps);
- }
-
- private executeMockShellBody(_ref: string, body: string, args: string[], params: string[]): StepResult {
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
- const { mkdtempSync, writeFileSync: wf, chmodSync } = require("node:fs") as typeof import("node:fs");
- const { join: pjoin } = require("node:path") as typeof import("node:path");
- const tmpDir = mkdtempSync(pjoin(require("node:os").tmpdir(), "jaiph-mock-"));
- const scriptPath = pjoin(tmpDir, "mock.sh");
- wf(scriptPath, `#!/usr/bin/env bash\nset -euo pipefail\n${body}\n`);
- chmodSync(scriptPath, 0o755);
- // Inject named params as env vars
- const env = { ...this.env };
- params.forEach((name, i) => {
- if (i < args.length) env[name] = args[i];
- });
- const r = spawnSync(scriptPath, args, {
- encoding: "utf8",
- cwd: this.cwd,
- env,
- });
- try { require("node:fs").rmSync(tmpDir, { recursive: true, force: true }); } catch {}
- return {
- status: r.status ?? 1,
- output: r.stdout ?? "",
- error: r.stderr ?? "",
- returnValue: (r.stdout ?? "").trim(),
- };
- }
}
diff --git a/src/runtime/kernel/prompt.test.ts b/src/runtime/kernel/prompt.test.ts
index 763fb422..b07606f9 100644
--- a/src/runtime/kernel/prompt.test.ts
+++ b/src/runtime/kernel/prompt.test.ts
@@ -7,12 +7,9 @@ import { PassThrough } from "node:stream";
import {
buildBackendArgs,
executePrompt,
- parseEtimeToSeconds,
prepareClaudeEnv,
resolveConfig,
resolveModel,
- selectTailToKill,
- type ProcNode,
type PromptConfig,
} from "./prompt";
@@ -265,28 +262,6 @@ describe("prepareClaudeEnv", () => {
});
});
-describe("tail watchdog helpers", () => {
- it("parses ps etime values", () => {
- assert.equal(parseEtimeToSeconds("00:59"), 59);
- assert.equal(parseEtimeToSeconds("01:02:03"), 3723);
- assert.equal(parseEtimeToSeconds("2-00:00:01"), 172801);
- });
-
- it("selects deepest stale tail descendant only", () => {
- const nodes: ProcNode[] = [
- { pid: 100, ppid: 1, elapsedSeconds: 5, command: "/usr/bin/node" },
- { pid: 200, ppid: 100, elapsedSeconds: 50, command: "/bin/zsh" },
- { pid: 300, ppid: 200, elapsedSeconds: 601, command: "/usr/bin/tail" },
- { pid: 310, ppid: 200, elapsedSeconds: 999, command: "/usr/bin/tail" },
- { pid: 320, ppid: 300, elapsedSeconds: 700, command: "/usr/bin/tail" },
- { pid: 400, ppid: 1, elapsedSeconds: 700, command: "/usr/bin/tail" },
- ];
- const selected = selectTailToKill(nodes, 100, 600);
- assert.ok(selected);
- assert.equal(selected?.pid, 320);
- });
-});
-
describe("codex backend", () => {
it("resolveConfig reads OPENAI_API_KEY and JAIPH_CODEX_API_URL", () => {
const config = resolveConfig({
diff --git a/src/runtime/kernel/prompt.ts b/src/runtime/kernel/prompt.ts
index b5137e5c..8a8569ad 100644
--- a/src/runtime/kernel/prompt.ts
+++ b/src/runtime/kernel/prompt.ts
@@ -1,16 +1,10 @@
-// JS kernel: prompt execution.
-// Called from bash: echo "$prompt_text" | node kernel/prompt.js [preview] [named_args...]
-// Env vars: JAIPH_AGENT_BACKEND, JAIPH_AGENT_COMMAND, JAIPH_AGENT_MODEL,
-// JAIPH_AGENT_TRUSTED_WORKSPACE, JAIPH_AGENT_CURSOR_FLAGS, JAIPH_AGENT_CLAUDE_FLAGS,
-// OPENAI_API_KEY, JAIPH_CODEX_API_URL,
-// JAIPH_WORKSPACE, JAIPH_TEST_MODE, JAIPH_MOCK_DISPATCH_SCRIPT,
-// JAIPH_MOCK_RESPONSES_FILE, JAIPH_PROMPT_FINAL_FILE
+// Prompt execution: spawn the configured agent backend and stream its output.
import { spawn as nodeSpawn } from "node:child_process";
import { writeFileSync, readFileSync, existsSync, accessSync, mkdirSync, cpSync, constants as fsConstants } from "node:fs";
import { basename, delimiter, join } from "node:path";
import { parseStream, type StreamWriter } from "./stream-parser";
-import { readNextMockResponse, mockDispatch } from "./mock";
+import { consumeNextMockResponse, dispatchMockArms, type MockPromptArm } from "./mock";
export type PromptConfig = {
backend: string;
@@ -184,16 +178,6 @@ type ClaudeEnvPreparation = {
error?: string;
};
-export type ProcNode = {
- pid: number;
- ppid: number;
- elapsedSeconds: number;
- command: string;
-};
-
-const DEFAULT_TAIL_KILL_POLL_MS = 15_000;
-const DEFAULT_TAIL_MAX_AGE_SECONDS = 10 * 60;
-
/**
* Ensure Claude CLI has a writable config/session directory.
* Falls back to workspace-local `.jaiph/claude-config` when home config is not writable.
@@ -217,6 +201,8 @@ export function prepareClaudeEnv(execEnv: NodeJS.ProcessEnv, workspaceRoot: stri
const fallbackConfigDir = join(workspaceRoot, ".jaiph", "claude-config");
try {
mkdirSync(fallbackConfigDir, { recursive: true });
+ // Seed the fallback with the user's existing config (auth, settings) so the
+ // Claude CLI keeps its credentials when only session-env was unwritable.
if (
configuredDir &&
configuredDir !== fallbackConfigDir &&
@@ -255,116 +241,6 @@ export function prepareClaudeEnv(execEnv: NodeJS.ProcessEnv, workspaceRoot: stri
}
}
-export function parseEtimeToSeconds(raw: string): number {
- const value = raw.trim();
- if (value.length === 0) return 0;
- const daySplit = value.split("-");
- const hasDays = daySplit.length === 2;
- const dayPart = hasDays ? daySplit[0] : "0";
- const timePart = hasDays ? daySplit[1] : daySplit[0];
- if (!timePart) return 0;
- const day = Number(dayPart);
- const parts = timePart.split(":").map((p) => Number(p));
- if (parts.some((n) => Number.isNaN(n))) return 0;
- let hour = 0;
- let minute = 0;
- let second = 0;
- if (parts.length === 3) {
- [hour, minute, second] = parts;
- } else if (parts.length === 2) {
- [minute, second] = parts;
- } else if (parts.length === 1) {
- [second] = parts;
- } else {
- return 0;
- }
- return day * 86_400 + hour * 3_600 + minute * 60 + second;
-}
-
-function isTailProcess(command: string): boolean {
- return /(^|\/)tail$/.test(command.trim());
-}
-
-function listProcessNodes(): ProcNode[] {
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
- const out = spawnSync("/bin/ps", ["-axo", "pid=,ppid=,etime=,comm="], { encoding: "utf8" });
- if (out.status !== 0) return [];
- const text = String(out.stdout ?? "");
- const nodes: ProcNode[] = [];
- for (const line of text.split(/\r?\n/)) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
- if (!m) continue;
- const pid = Number(m[1]);
- const ppid = Number(m[2]);
- const elapsedSeconds = parseEtimeToSeconds(m[3] ?? "");
- const command = m[4] ?? "";
- if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
- nodes.push({ pid, ppid, elapsedSeconds, command });
- }
- return nodes;
-}
-
-export function selectTailToKill(nodes: ProcNode[], rootPid: number, minAgeSeconds: number): ProcNode | undefined {
- const byParent = new Map();
- for (const n of nodes) {
- const arr = byParent.get(n.ppid) ?? [];
- arr.push(n);
- byParent.set(n.ppid, arr);
- }
- const queue: Array<{ pid: number; depth: number }> = [{ pid: rootPid, depth: 0 }];
- const depthByPid = new Map([[rootPid, 0]]);
- while (queue.length > 0) {
- const cur = queue.shift()!;
- const children = byParent.get(cur.pid) ?? [];
- for (const child of children) {
- if (depthByPid.has(child.pid)) continue;
- const depth = cur.depth + 1;
- depthByPid.set(child.pid, depth);
- queue.push({ pid: child.pid, depth });
- }
- }
- let best: { node: ProcNode; depth: number } | undefined;
- for (const n of nodes) {
- const depth = depthByPid.get(n.pid);
- if (depth === undefined) continue;
- if (!isTailProcess(n.command)) continue;
- if (n.elapsedSeconds < minAgeSeconds) continue;
- if (!best || depth > best.depth || (depth === best.depth && n.elapsedSeconds > best.node.elapsedSeconds)) {
- best = { node: n, depth };
- }
- }
- return best?.node;
-}
-
-function startTailWatchdog(rootPid: number, stderr: NodeJS.WritableStream, env: NodeJS.ProcessEnv): () => void {
- const pollMs = Number(env.JAIPH_PROMPT_TAIL_KILL_POLL_MS ?? String(DEFAULT_TAIL_KILL_POLL_MS));
- const minAgeSeconds = Number(env.JAIPH_PROMPT_TAIL_MAX_AGE_SECONDS ?? String(DEFAULT_TAIL_MAX_AGE_SECONDS));
- if (!Number.isFinite(pollMs) || pollMs <= 0 || !Number.isFinite(minAgeSeconds) || minAgeSeconds <= 0) {
- return () => {};
- }
- const timer = setInterval(() => {
- try {
- const nodes = listProcessNodes();
- const target = selectTailToKill(nodes, rootPid, minAgeSeconds);
- if (!target) return;
- try {
- process.kill(target.pid, "SIGTERM");
- stderr.write(
- `jaiph: killed stale tail subprocess pid=${target.pid} age_s=${target.elapsedSeconds} (threshold=${minAgeSeconds})\n`,
- );
- } catch {
- // Process may already be gone; ignore.
- }
- } catch {
- // Best-effort watchdog only.
- }
- }, pollMs);
- timer.unref();
- return () => clearInterval(timer);
-}
-
const CODEX_DEFAULT_MODEL = "gpt-4o";
/** Run a prompt against the OpenAI Chat Completions API with streaming. */
@@ -526,10 +402,8 @@ function runBackend(
stdio: useStdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
env: childEnv,
});
- const stopTailWatchdog = startTailWatchdog(child.pid ?? -1, stderr, childEnv);
child.on("error", (err) => {
- stopTailWatchdog();
stderr.write(`jaiph: failed to start ${command}: ${err.message}\n`);
resolve({ final: "", status: 1 });
});
@@ -554,7 +428,6 @@ function runBackend(
final += text;
});
child.on("close", (code) => {
- stopTailWatchdog();
resolve({ final, status: code ?? 0 });
});
return;
@@ -577,11 +450,9 @@ function runBackend(
parseStream(parseInput, writer).then((final) => {
child.on("close", (code) => {
- stopTailWatchdog();
resolve({ final, status: code ?? 0 });
});
if (child.exitCode !== null) {
- stopTailWatchdog();
resolve({ final, status: child.exitCode });
}
});
@@ -637,9 +508,16 @@ export async function executePrompt(
// Test mode: check mocks first
if (isTestMode(execEnv)) {
- const dispatchScript = execEnv.JAIPH_MOCK_DISPATCH_SCRIPT || "";
- if (dispatchScript) {
- const result = mockDispatch(promptText, dispatchScript);
+ const armsJson = execEnv.JAIPH_MOCK_PROMPT_ARMS_JSON || "";
+ if (armsJson) {
+ let arms: MockPromptArm[] = [];
+ try {
+ arms = JSON.parse(armsJson) as MockPromptArm[];
+ } catch {
+ stderr.write(`jaiph: invalid JAIPH_MOCK_PROMPT_ARMS_JSON\n`);
+ return { final: "", status: 1 };
+ }
+ const result = dispatchMockArms(promptText, arms);
if (result.status === 0) {
writeFinalFile(config.promptFinalFile, result.response);
stdout.write(result.response);
@@ -650,9 +528,9 @@ export async function executePrompt(
}
return { final: "", status: result.status };
}
- const mockFile = execEnv.JAIPH_MOCK_RESPONSES_FILE || "";
- if (mockFile) {
- const mockResult = readNextMockResponse(mockFile);
+ const responsesJson = execEnv.JAIPH_MOCK_RESPONSES_JSON || "";
+ if (responsesJson) {
+ const mockResult = consumeNextMockResponse(responsesJson);
if (mockResult !== null) {
writeFinalFile(config.promptFinalFile, mockResult);
stdout.write(mockResult);
diff --git a/src/runtime/kernel/run-step-exec.ts b/src/runtime/kernel/run-step-exec.ts
deleted file mode 100644
index 57287753..00000000
--- a/src/runtime/kernel/run-step-exec.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Subprocess execution for managed Jaiph steps (script / workflow / rule).
- * Managed subprocess execution for script steps.
- */
-import { spawnSync } from "node:child_process";
-import { closeSync, existsSync, fstatSync, openSync, writeFileSync } from "node:fs";
-
-const MAX_BUFFER = 256 * 1024 * 1024;
-const MAX_DISPATCH_DEPTH = 200;
-
-/**
- * Node only wires stdio 0–2 unless we extend the array. Nested managed-step children must keep the
- * same __JAIPH_EVENT__ fd as the parent (see jaiph::event_fd: 3 when open, else 2). Without passing
- * it through, child events fall back to stderr — redirected here to step .err files — so events
- * only reach the CLI when the step ends.
- *
- * We read the fd number from the parent shell (env) and refuse to duplicate if it equals an
- * already-mapped capture fd; otherwise `openSync` could occupy fd 3 and `fstatSync(3)` would lie.
- */
-function stdioWithJaiphEventFd(
- stdin: "inherit" | "ignore",
- stdout: number | "pipe",
- stderr: number,
-): Array<"inherit" | "ignore" | "pipe" | number> {
- const io: Array<"inherit" | "ignore" | "pipe" | number> = [stdin, stdout, stderr];
- const raw = process.env.JAIPH_RUN_STEP_KERNEL_EXTRA_FD;
- if (raw === undefined || raw === "") return io;
- const fd = Number(raw);
- if (fd !== 2 && fd !== 3) return io;
- if (fd === stdout || fd === stderr) return io;
- try {
- fstatSync(fd);
- } catch {
- return io;
- }
- io.push(fd);
- return io;
-}
-
-function isolatedScriptEnv(): NodeJS.ProcessEnv {
- return {
- PATH: process.env.PATH ?? "",
- HOME: process.env.HOME ?? "",
- TERM: process.env.TERM ?? "",
- USER: process.env.USER ?? "",
- JAIPH_SCRIPTS: process.env.JAIPH_SCRIPTS ?? "",
- JAIPH_WORKSPACE: process.env.JAIPH_WORKSPACE ?? "",
- };
-}
-
-function runScriptCapture(exe: string, args: string[], outPath: string, errPath: string): number {
- const outFd = openSync(outPath, "w");
- const errFd = openSync(errPath, "w");
- const r = spawnSync(exe, args, {
- stdio: ["ignore", outFd, errFd],
- env: isolatedScriptEnv(),
- cwd: process.cwd(),
- maxBuffer: MAX_BUFFER,
- });
- closeSync(outFd);
- closeSync(errFd);
- if (r.error) {
- process.stderr.write(`jaiph run-step-exec: ${r.error.message}\n`);
- return 1;
- }
- return r.status ?? 1;
-}
-
-function runScriptTee(exe: string, args: string[], outPath: string, errPath: string): number {
- const errFd = openSync(errPath, "w");
- const r = spawnSync(exe, args, {
- stdio: ["ignore", "pipe", errFd],
- env: isolatedScriptEnv(),
- cwd: process.cwd(),
- maxBuffer: MAX_BUFFER,
- });
- closeSync(errFd);
- if (r.error) {
- process.stderr.write(`jaiph run-step-exec: ${r.error.message}\n`);
- return 1;
- }
- const buf = r.stdout ?? Buffer.alloc(0);
- process.stdout.write(buf);
- writeFileSync(outPath, buf);
- return r.status ?? 1;
-}
-
-function runModuleDispatchCommand(cmdArgs: string[], outPath: string, errPath: string, useTee: boolean): number {
- const mod = process.env.JAIPH_RUN_STEP_MODULE;
- if (!mod || !existsSync(mod)) {
- process.stderr.write("jaiph run-step-exec: JAIPH_RUN_STEP_MODULE must name an existing workflow module\n");
- return 1;
- }
- const rawDepth = process.env.JAIPH_RUN_STEP_DISPATCH_DEPTH;
- const depth = rawDepth && /^\d+$/.test(rawDepth) ? parseInt(rawDepth, 10) : 0;
- if (depth >= MAX_DISPATCH_DEPTH) {
- process.stderr.write(
- `jaiph run-step-exec: dispatch depth exceeded ${MAX_DISPATCH_DEPTH} (possible recursive module dispatch)\n`,
- );
- return 1;
- }
- const childEnv: NodeJS.ProcessEnv = {
- ...process.env,
- JAIPH_RUN_STEP_DISPATCH_DEPTH: String(depth + 1),
- };
- const argv = ["__jaiph_dispatch", ...cmdArgs];
- if (useTee) {
- const errFd = openSync(errPath, "w");
- const r = spawnSync(mod, argv, {
- stdio: stdioWithJaiphEventFd("inherit", "pipe", errFd),
- env: childEnv,
- cwd: process.cwd(),
- maxBuffer: MAX_BUFFER,
- });
- closeSync(errFd);
- if (r.error) {
- process.stderr.write(`jaiph run-step-exec: ${r.error.message}\n`);
- return 1;
- }
- const buf = r.stdout ?? Buffer.alloc(0);
- process.stdout.write(buf);
- writeFileSync(outPath, buf);
- return r.status ?? 1;
- }
- const outFd = openSync(outPath, "w");
- const errFd = openSync(errPath, "w");
- const r = spawnSync(mod, argv, {
- stdio: stdioWithJaiphEventFd("inherit", outFd, errFd),
- env: childEnv,
- cwd: process.cwd(),
- maxBuffer: MAX_BUFFER,
- });
- closeSync(outFd);
- closeSync(errFd);
- if (r.error) {
- process.stderr.write(`jaiph run-step-exec: ${r.error.message}\n`);
- return 1;
- }
- return r.status ?? 1;
-}
-
-function main(): number {
- const funcName = process.argv[2];
- const stepKind = process.argv[3];
- const cmdArgs = process.argv.slice(4);
- const outTmp = process.env.JAIPH_RUN_STEP_OUT_TMP;
- const errTmp = process.env.JAIPH_RUN_STEP_ERR_TMP;
- if (!funcName || !stepKind) {
- process.stderr.write("jaiph run-step-exec: missing func_name or step_kind\n");
- return 1;
- }
- if (!outTmp || !errTmp) {
- process.stderr.write("jaiph run-step-exec: JAIPH_RUN_STEP_OUT_TMP and JAIPH_RUN_STEP_ERR_TMP required\n");
- return 1;
- }
- if (cmdArgs.length === 0) {
- process.stderr.write("jaiph run-step-exec: missing command argv\n");
- return 1;
- }
-
- const useTee = process.env.JAIPH_RUN_STEP_USE_TEE === "1";
- if (stepKind === "script") {
- const exe = cmdArgs[0]!;
- const args = cmdArgs.slice(1);
- return useTee ? runScriptTee(exe, args, outTmp, errTmp) : runScriptCapture(exe, args, outTmp, errTmp);
- }
- if (stepKind === "workflow" || stepKind === "rule") {
- return runModuleDispatchCommand(cmdArgs, outTmp, errTmp, useTee);
- }
-
- process.stderr.write(`jaiph run-step-exec: unsupported step_kind ${stepKind}\n`);
- return 1;
-}
-
-process.exit(main());
diff --git a/src/runtime/kernel/runtime-arg-parser.test.ts b/src/runtime/kernel/runtime-arg-parser.test.ts
new file mode 100644
index 00000000..c7c4bb44
--- /dev/null
+++ b/src/runtime/kernel/runtime-arg-parser.test.ts
@@ -0,0 +1,239 @@
+import { describe, it } from "node:test";
+import * as assert from "node:assert/strict";
+import {
+ BARE_IDENT_RE,
+ commaArgsToInterpolated,
+ interpolate,
+ parseArgTokens,
+ parseArgsRaw,
+ parseInlineCaptureCall,
+ parseInlineScriptAt,
+ parseManagedArgAt,
+ parsePromptSchema,
+ sanitizeName,
+ stripOuterQuotes,
+} from "./runtime-arg-parser";
+
+describe("sanitizeName", () => {
+ it("preserves alphanumeric, _, -, .", () => {
+ assert.equal(sanitizeName("abc_123.def-ghi"), "abc_123.def-ghi");
+ });
+
+ it("replaces unsafe chars with underscore", () => {
+ assert.equal(sanitizeName("foo/bar baz"), "foo_bar_baz");
+ assert.equal(sanitizeName("a:b@c#d"), "a_b_c_d");
+ });
+});
+
+describe("interpolate", () => {
+ it("substitutes ${var} from the vars map", () => {
+ const vars = new Map([["name", "world"]]);
+ assert.equal(interpolate("hello ${name}", vars), "hello world");
+ });
+
+ it("falls back to env when var is not in scope", () => {
+ const vars = new Map();
+ assert.equal(interpolate("home=${HOME}", vars, { HOME: "/tmp" }), "home=/tmp");
+ });
+
+ it("returns empty string for missing identifiers", () => {
+ assert.equal(interpolate("[${missing}]", new Map()), "[]");
+ });
+
+ it("supports ${var.field} JSON dot access", () => {
+ const vars = new Map([["user", JSON.stringify({ name: "Adam", age: 30 })]]);
+ assert.equal(interpolate("hi ${user.name}, age ${user.age}", vars), "hi Adam, age 30");
+ });
+
+ it("returns empty string for ${var.field} when base is not JSON", () => {
+ const vars = new Map([["bad", "not-json"]]);
+ assert.equal(interpolate("[${bad.field}]", vars), "[]");
+ });
+});
+
+describe("parseInlineCaptureCall", () => {
+ it("parses paren form: ref(args)", () => {
+ assert.deepEqual(parseInlineCaptureCall("greet(x, y)"), { ref: "greet", argsRaw: "x, y" });
+ });
+
+ it("parses bareword form: ref args", () => {
+ assert.deepEqual(parseInlineCaptureCall("greet x y"), { ref: "greet", argsRaw: "x y" });
+ });
+
+ it("parses ref with no args", () => {
+ assert.deepEqual(parseInlineCaptureCall("greet"), { ref: "greet", argsRaw: "" });
+ });
+
+ it("supports dotted refs", () => {
+ assert.deepEqual(parseInlineCaptureCall("mod.greet()"), { ref: "mod.greet", argsRaw: "" });
+ });
+});
+
+describe("commaArgsToInterpolated", () => {
+ it("wraps bare identifiers in ${...} and space-separates", () => {
+ assert.equal(commaArgsToInterpolated("a, b, c"), "${a} ${b} ${c}");
+ });
+
+ it("leaves quoted/literal tokens intact", () => {
+ assert.equal(commaArgsToInterpolated('"hello", x, 42'), '"hello" ${x} 42');
+ });
+
+ it("returns empty string for empty input", () => {
+ assert.equal(commaArgsToInterpolated(""), "");
+ assert.equal(commaArgsToInterpolated(" "), "");
+ });
+});
+
+describe("parseArgsRaw", () => {
+ it("splits on whitespace and interpolates each token", () => {
+ const vars = new Map([["name", "world"]]);
+ assert.deepEqual(parseArgsRaw("hello ${name} 42", vars), ["hello", "world", "42"]);
+ });
+
+ it("respects single- and double-quoted spans", () => {
+ assert.deepEqual(parseArgsRaw('"hello world" foo', new Map()), ["hello world", "foo"]);
+ assert.deepEqual(parseArgsRaw("'a b' c", new Map()), ["a b", "c"]);
+ });
+
+ it("returns empty array for empty input", () => {
+ assert.deepEqual(parseArgsRaw("", new Map()), []);
+ });
+});
+
+describe("parseInlineScriptAt", () => {
+ it("parses inline-script form `body`(args)", () => {
+ const result = parseInlineScriptAt("`echo hi`(arg1 arg2) rest");
+ assert.ok(result);
+ assert.equal(result!.body, "echo hi");
+ assert.equal(result!.argsRaw, "arg1 arg2");
+ // consumed includes everything up to and including the closing paren
+ assert.equal(result!.consumed, "`echo hi`(arg1 arg2)".length);
+ });
+
+ it("returns null when input does not start with backtick", () => {
+ assert.equal(parseInlineScriptAt("not backtick"), null);
+ });
+
+ it("returns null when paren is unbalanced", () => {
+ assert.equal(parseInlineScriptAt("`body`(unclosed"), null);
+ });
+});
+
+describe("parseManagedArgAt", () => {
+ it("parses `run ref(args)` form (bare idents already wrapped by parseCallRef)", () => {
+ const result = parseManagedArgAt("run greet(x)", 0);
+ assert.ok(result);
+ assert.equal(result!.token.kind, "managed");
+ if (result!.token.kind === "managed") {
+ assert.equal(result!.token.managedKind, "run");
+ assert.equal(result!.token.ref, "greet");
+ assert.equal(result!.token.argsRaw, "${x}");
+ }
+ });
+
+ it("parses `ensure ref(args)` form", () => {
+ const result = parseManagedArgAt("ensure check(a, b)", 0);
+ assert.ok(result);
+ if (result!.token.kind === "managed") {
+ assert.equal(result!.token.managedKind, "ensure");
+ assert.equal(result!.token.ref, "check");
+ assert.equal(result!.token.argsRaw, "${a} ${b}");
+ }
+ });
+
+ it("parses `run \\`body\\`(args)` as inline script", () => {
+ const result = parseManagedArgAt("run `echo hi`(x)", 0);
+ assert.ok(result);
+ if (result!.token.kind === "managed_inline_script") {
+ assert.equal(result!.token.body, "echo hi");
+ assert.equal(result!.token.argsRaw, "x");
+ } else {
+ assert.fail(`expected managed_inline_script, got ${result!.token.kind}`);
+ }
+ });
+
+ it("returns null when not a run/ensure prefix", () => {
+ assert.equal(parseManagedArgAt("foo bar", 0), null);
+ });
+});
+
+describe("parseArgTokens", () => {
+ it("returns literal tokens for plain args", () => {
+ const tokens = parseArgTokens("a b c");
+ assert.equal(tokens.length, 3);
+ assert.deepEqual(tokens.map((t) => t.kind), ["literal", "literal", "literal"]);
+ });
+
+ it("recognises managed run/ensure tokens within a list", () => {
+ const tokens = parseArgTokens("foo run greet(x) bar");
+ assert.equal(tokens.length, 3);
+ assert.equal(tokens[0].kind, "literal");
+ assert.equal(tokens[1].kind, "managed");
+ assert.equal(tokens[2].kind, "literal");
+ });
+
+ it("returns empty array for empty input", () => {
+ assert.deepEqual(parseArgTokens(""), []);
+ });
+});
+
+describe("stripOuterQuotes", () => {
+ it('removes matching double quotes', () => {
+ assert.equal(stripOuterQuotes('"hello"'), "hello");
+ });
+
+ it("removes matching single quotes", () => {
+ assert.equal(stripOuterQuotes("'hello'"), "hello");
+ });
+
+ it("leaves unquoted strings unchanged", () => {
+ assert.equal(stripOuterQuotes("hello"), "hello");
+ });
+
+ it("leaves mismatched quotes unchanged", () => {
+ assert.equal(stripOuterQuotes("\"hello'"), "\"hello'");
+ });
+});
+
+describe("parsePromptSchema", () => {
+ it("parses a flat object schema with three types", () => {
+ const fields = parsePromptSchema("{ name: string, age: number, active: boolean }");
+ assert.deepEqual(fields, [
+ { name: "name", type: "string" },
+ { name: "age", type: "number" },
+ { name: "active", type: "boolean" },
+ ]);
+ });
+
+ it("returns empty array for empty schema", () => {
+ assert.deepEqual(parsePromptSchema(""), []);
+ assert.deepEqual(parsePromptSchema("{}"), []);
+ });
+
+ it("throws on union/array syntax", () => {
+ assert.throws(() => parsePromptSchema("{ x: string | number }"));
+ assert.throws(() => parsePromptSchema("{ xs: string[] }"));
+ });
+
+ it("throws on unsupported type", () => {
+ assert.throws(() => parsePromptSchema("{ x: object }"));
+ });
+
+ it("throws on malformed entry", () => {
+ assert.throws(() => parsePromptSchema("{ no_colon }"));
+ });
+});
+
+describe("BARE_IDENT_RE", () => {
+ it("matches valid identifier characters", () => {
+ assert.ok(BARE_IDENT_RE.test("foo"));
+ assert.ok(BARE_IDENT_RE.test("_bar"));
+ assert.ok(BARE_IDENT_RE.test("a1_b2"));
+ });
+
+ it("rejects names that start with a digit or contain spaces/dashes", () => {
+ assert.ok(!BARE_IDENT_RE.test("1abc"));
+ assert.ok(!BARE_IDENT_RE.test("a b"));
+ assert.ok(!BARE_IDENT_RE.test("a-b"));
+ });
+});
diff --git a/src/runtime/kernel/runtime-arg-parser.ts b/src/runtime/kernel/runtime-arg-parser.ts
new file mode 100644
index 00000000..b09db127
--- /dev/null
+++ b/src/runtime/kernel/runtime-arg-parser.ts
@@ -0,0 +1,248 @@
+/**
+ * Stateless parsing/formatting helpers for the Node workflow runtime.
+ *
+ * Pure functions only — no I/O, no class state. The runtime composes these to
+ * resolve interpolated strings, parse call argument lists (including managed
+ * `run`/`ensure` and inline-script forms), and validate prompt return schemas.
+ */
+import { parseCallRef } from "../../parse/core";
+import { formatUtcTimestamp } from "./emit";
+
+export const BARE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
+export const MAX_EMBED = 1024 * 1024;
+export const MAX_RECURSION_DEPTH = 256;
+
+export type ParsedArgToken =
+ | { kind: "literal"; value: string }
+ | { kind: "managed"; managedKind: "run" | "ensure"; ref: string; argsRaw: string }
+ | { kind: "managed_inline_script"; body: string; lang?: string; argsRaw: string };
+
+export type PromptSchemaField = { name: string; type: "string" | "number" | "boolean" };
+
+export function sanitizeName(raw: string): string {
+ return raw.replace(/[^a-zA-Z0-9_.-]/g, "_");
+}
+
+export function nowIso(): string {
+ return formatUtcTimestamp();
+}
+
+export function interpolate(input: string, vars: Map, env?: NodeJS.ProcessEnv): string {
+ const lookup = (key: string): string => vars.get(key) ?? env?.[key] ?? "";
+ return input.replace(/\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\.([a-zA-Z_][a-zA-Z0-9_]*))?\}/g, (_m, base, field) => {
+ if (!field) return lookup(String(base));
+ // Dot field access: parse JSON stored in the base variable and extract the field.
+ const raw = lookup(String(base));
+ try {
+ const obj = JSON.parse(raw);
+ return obj != null && typeof obj === "object" && field in obj ? String(obj[field]) : "";
+ } catch {
+ return "";
+ }
+ });
+}
+
+/** Body after "run" / "ensure" in ${run ...} / ${ensure ...} (e.g. greet(), greet(x), or greet x). */
+export function parseInlineCaptureCall(body: string): { ref: string; argsRaw: string } {
+ const trimmed = body.trim();
+ const paren = trimmed.match(/^([\w.]+)\s*\(([^)]*)\)\s*$/);
+ if (paren) {
+ return { ref: paren[1], argsRaw: paren[2].trim() };
+ }
+ const spaceIdx = trimmed.indexOf(" ");
+ if (spaceIdx === -1) {
+ return { ref: trimmed, argsRaw: "" };
+ }
+ return { ref: trimmed.slice(0, spaceIdx), argsRaw: trimmed.slice(spaceIdx + 1).trim() };
+}
+
+/** Convert comma-separated call args (as written in source) to space-separated form with bare identifiers wrapped in ${…}. */
+export function commaArgsToInterpolated(raw: string): string {
+ if (!raw.trim()) return "";
+ return raw.split(",").map((seg) => {
+ const t = seg.trim();
+ return BARE_IDENT_RE.test(t) ? `\${${t}}` : t;
+ }).join(" ");
+}
+
+export function parseArgsRaw(raw: string, vars: Map, env?: NodeJS.ProcessEnv): string[] {
+ if (!raw.trim()) return [];
+ const out: string[] = [];
+ let cur = "";
+ let quote: "'" | '"' | null = null;
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i]!;
+ if (quote) {
+ if (ch === quote) {
+ quote = null;
+ } else {
+ cur += ch;
+ }
+ continue;
+ }
+ if (ch === "'" || ch === '"') {
+ quote = ch;
+ continue;
+ }
+ if (/\s/.test(ch)) {
+ if (cur.length > 0) {
+ out.push(interpolate(cur, vars, env));
+ cur = "";
+ }
+ continue;
+ }
+ cur += ch;
+ }
+ if (cur.length > 0) {
+ out.push(interpolate(cur, vars, env));
+ }
+ return out;
+}
+
+/** Try to parse `\`body\`(args)` from a string at a given position. */
+export function parseInlineScriptAt(s: string): { body: string; argsRaw: string; consumed: number } | null {
+ const t = s.trimStart();
+ const skippedWs = s.length - t.length;
+ if (!t.startsWith("`")) return null;
+ const closeIdx = t.indexOf("`", 1);
+ if (closeIdx === -1) return null;
+ const body = t.slice(1, closeIdx);
+ const afterClose = t.slice(closeIdx + 1);
+ if (!afterClose.startsWith("(")) return null;
+ let depth = 1;
+ let i = 1;
+ let inQuote: string | null = null;
+ while (i < afterClose.length && depth > 0) {
+ const ch = afterClose[i];
+ if (inQuote) {
+ if (ch === inQuote && afterClose[i - 1] !== "\\") inQuote = null;
+ } else {
+ if (ch === '"' || ch === "'") inQuote = ch;
+ else if (ch === "(") depth++;
+ else if (ch === ")") depth--;
+ }
+ i++;
+ }
+ if (depth !== 0) return null;
+ const argsContent = afterClose.slice(1, i - 1).trim();
+ return { body, argsRaw: argsContent, consumed: skippedWs + closeIdx + 1 + i };
+}
+
+export function parseManagedArgAt(raw: string, start: number): { token: ParsedArgToken; next: number } | null {
+ const tail = raw.slice(start);
+ const keyword = tail.startsWith("run ")
+ ? "run"
+ : tail.startsWith("ensure ")
+ ? "ensure"
+ : null;
+ if (!keyword) return null;
+ const afterKeyword = raw.slice(start + keyword.length).trimStart();
+ const skipped = raw.slice(start + keyword.length).length - afterKeyword.length;
+ const call = parseCallRef(afterKeyword);
+ if (call && (call.rest.length === 0 || /^\s/.test(call.rest))) {
+ const consumed = afterKeyword.length - call.rest.length;
+ return {
+ token: {
+ kind: "managed",
+ managedKind: keyword,
+ ref: call.ref,
+ argsRaw: call.args ?? "",
+ },
+ next: start + keyword.length + skipped + consumed,
+ };
+ }
+ // Try inline script form: run `body`(args)
+ if (keyword === "run") {
+ const inlineResult = parseInlineScriptAt(afterKeyword);
+ if (inlineResult) {
+ return {
+ token: {
+ kind: "managed_inline_script",
+ body: inlineResult.body,
+ argsRaw: inlineResult.argsRaw,
+ },
+ next: start + keyword.length + skipped + inlineResult.consumed,
+ };
+ }
+ }
+ return null;
+}
+
+export function parseArgTokens(raw: string): ParsedArgToken[] {
+ if (!raw.trim()) return [];
+ const out: ParsedArgToken[] = [];
+ let i = 0;
+ while (i < raw.length) {
+ while (i < raw.length && /\s/.test(raw[i]!)) i += 1;
+ if (i >= raw.length) break;
+ const managed = parseManagedArgAt(raw, i);
+ if (managed) {
+ out.push(managed.token);
+ i = managed.next;
+ continue;
+ }
+ let cur = "";
+ let quote: "'" | '"' | null = null;
+ while (i < raw.length) {
+ const ch = raw[i]!;
+ if (quote) {
+ if (ch === quote) {
+ quote = null;
+ } else {
+ cur += ch;
+ }
+ i += 1;
+ continue;
+ }
+ if (ch === "'" || ch === '"') {
+ quote = ch;
+ i += 1;
+ continue;
+ }
+ if (/\s/.test(ch)) {
+ break;
+ }
+ cur += ch;
+ i += 1;
+ }
+ if (cur.length > 0) {
+ out.push({ kind: "literal", value: cur });
+ }
+ }
+ return out;
+}
+
+export function stripOuterQuotes(value: string): string {
+ if (value.length >= 2) {
+ const first = value[0];
+ const last = value[value.length - 1];
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
+ return value.slice(1, -1);
+ }
+ }
+ return value;
+}
+
+export function parsePromptSchema(rawSchema: string): PromptSchemaField[] {
+ const trimmed = rawSchema.trim();
+ if (trimmed.length === 0) return [];
+ if (/[[\]|]/.test(trimmed)) {
+ throw new Error("returns schema must be flat (no arrays or union types)");
+ }
+ const inner = trimmed.replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "").trim();
+ if (inner.length === 0) return [];
+ const fields: PromptSchemaField[] = [];
+ for (const part of inner.split(",")) {
+ const m = part.trim().match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(\S+)\s*$/);
+ if (!m) {
+ throw new Error(`invalid returns schema entry: ${part.trim().slice(0, 40)}`);
+ }
+ const [, name, typeStr] = m;
+ const type = typeStr.toLowerCase();
+ if (type !== "string" && type !== "number" && type !== "boolean") {
+ throw new Error(`unsupported returns schema type: ${typeStr}`);
+ }
+ fields.push({ name, type: type as "string" | "number" | "boolean" });
+ }
+ return fields;
+}
diff --git a/src/runtime/kernel/runtime-event-emitter.ts b/src/runtime/kernel/runtime-event-emitter.ts
new file mode 100644
index 00000000..90330e73
--- /dev/null
+++ b/src/runtime/kernel/runtime-event-emitter.ts
@@ -0,0 +1,207 @@
+/**
+ * Live + durable event emission for the Node workflow runtime.
+ *
+ * Owns the `__JAIPH_EVENT__` stderr stream and `run_summary.jsonl` writes for
+ * workflow/step/prompt/log events, plus the monotonic step + prompt sequence
+ * counters used by both the orchestrator and the prompt pipeline.
+ */
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { appendRunSummaryLine } from "./emit";
+import { MAX_EMBED, nowIso, sanitizeName, stripOuterQuotes } from "./runtime-arg-parser";
+
+export type Frame = {
+ id: string;
+ kind: string;
+ name: string;
+};
+
+export type PromptStepHandle = {
+ id: string;
+ seq: number;
+ outFile: string;
+ errFile: string;
+ backend: string;
+ startedAtMs: number;
+};
+
+export type RuntimeEventEmitterDeps = {
+ runId: string;
+ runDir: string;
+ env: NodeJS.ProcessEnv;
+ getFrameStack: () => Frame[];
+ getAsyncIndices: () => number[];
+ /**
+ * When true, skip writing `__JAIPH_EVENT__` lines to stderr. Durable
+ * `run_summary.jsonl` writes are unaffected. Set by in-process callers
+ * (e.g. the test runner) that construct the runtime in their own Node
+ * process and don't want event lines swamping reporter output.
+ */
+ suppressLiveEvents?: boolean;
+};
+
+export class RuntimeEventEmitter {
+ private readonly runId: string;
+ private readonly runDir: string;
+ private readonly env: NodeJS.ProcessEnv;
+ private readonly getFrameStack: () => Frame[];
+ private readonly getAsyncIndices: () => number[];
+ private readonly suppressLiveEvents: boolean;
+ private stepSeq = 0;
+ private promptSeq = 0;
+
+ constructor(deps: RuntimeEventEmitterDeps) {
+ this.runId = deps.runId;
+ this.runDir = deps.runDir;
+ this.env = deps.env;
+ this.getFrameStack = deps.getFrameStack;
+ this.getAsyncIndices = deps.getAsyncIndices;
+ this.suppressLiveEvents = deps.suppressLiveEvents ?? false;
+ }
+
+ allocStepSeq(): number {
+ this.stepSeq += 1;
+ return this.stepSeq;
+ }
+
+ emitWorkflow(type: "WORKFLOW_START" | "WORKFLOW_END", workflow: string): void {
+ appendRunSummaryLine(
+ JSON.stringify({
+ type,
+ workflow,
+ source: this.env.JAIPH_SOURCE_FILE ?? "",
+ ts: nowIso(),
+ run_id: this.runId,
+ event_version: 1,
+ }),
+ );
+ }
+
+ emitStep(payload: Record): void {
+ const indices = this.getAsyncIndices();
+ const full = indices.length > 0 ? { ...payload, async_indices: indices } : payload;
+ if (!this.suppressLiveEvents) {
+ process.stderr.write(`__JAIPH_EVENT__ ${JSON.stringify(full)}\n`);
+ }
+ appendRunSummaryLine(JSON.stringify({ ...full, event_version: 1 }));
+ }
+
+ emitPromptEvent(
+ type: "PROMPT_START" | "PROMPT_END",
+ payload: { backend: string; model?: string; model_reason?: string; status?: number; preview?: string },
+ ): void {
+ const stack = this.getFrameStack();
+ const current = stack.length > 0 ? stack[stack.length - 1] : null;
+ appendRunSummaryLine(
+ JSON.stringify({
+ type,
+ ts: nowIso(),
+ run_id: this.runId,
+ depth: stack.length,
+ step_id: current?.id ?? null,
+ step_name: current?.name ?? null,
+ backend: payload.backend,
+ model: payload.model ?? null,
+ model_reason: payload.model_reason ?? null,
+ status: payload.status ?? null,
+ preview: payload.preview ?? null,
+ event_version: 1,
+ }),
+ );
+ }
+
+ emitPromptStepStart(
+ backend: string,
+ scopeVars: Map,
+ rawPromptSource: string,
+ ): PromptStepHandle {
+ this.promptSeq += 1;
+ const seq = this.allocStepSeq();
+ const stack = this.getFrameStack();
+ const current = stack.length > 0 ? stack[stack.length - 1] : null;
+ const id = `${this.runId}:${process.pid}:prompt:${this.promptSeq}`;
+ const safe = sanitizeName("prompt__prompt");
+ const outFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.out`);
+ const errFile = join(this.runDir, `${String(seq).padStart(6, "0")}-${safe}.err`);
+ writeFileSync(outFile, "");
+ writeFileSync(errFile, "");
+ // Preview keeps the authored `${var}` placeholders rather than substituted values,
+ // so the tree shows what the user wrote; concrete values live alongside in params.
+ const preview = stripOuterQuotes(rawPromptSource).replace(/\s+/g, " ").trim();
+ const params: Array<[string, string]> = [["prompt_text", preview]];
+ const seen = new Set(["prompt_text"]);
+ // Include named vars referenced in the prompt text.
+ const refRe = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = refRe.exec(rawPromptSource)) !== null) {
+ const name = m[1];
+ if (!seen.has(name)) {
+ seen.add(name);
+ const val = scopeVars.get(name) ?? "";
+ if (val.length > 0) params.push([name, val]);
+ }
+ }
+ this.emitStep({
+ type: "STEP_START",
+ func: "prompt",
+ kind: "prompt",
+ name: backend,
+ ts: nowIso(),
+ status: null,
+ elapsed_ms: null,
+ out_file: outFile,
+ err_file: errFile,
+ id,
+ parent_id: current?.id ?? null,
+ seq,
+ depth: stack.length,
+ run_id: this.runId,
+ params,
+ });
+ return { id, seq, outFile, errFile, backend, startedAtMs: Date.now() };
+ }
+
+ emitPromptStepEnd(prompt: PromptStepHandle, status: number, outContent: string, errContent: string): void {
+ const stack = this.getFrameStack();
+ const current = stack.length > 0 ? stack[stack.length - 1] : null;
+ if (errContent.length > 0) {
+ writeFileSync(prompt.errFile, errContent);
+ }
+ this.emitStep({
+ type: "STEP_END",
+ func: "prompt",
+ kind: "prompt",
+ name: prompt.backend,
+ ts: nowIso(),
+ status,
+ elapsed_ms: Date.now() - prompt.startedAtMs,
+ out_file: prompt.outFile,
+ err_file: prompt.errFile,
+ id: prompt.id,
+ parent_id: current?.id ?? null,
+ seq: prompt.seq,
+ depth: stack.length,
+ run_id: this.runId,
+ params: [],
+ out_content: outContent.slice(0, MAX_EMBED),
+ err_content: status !== 0 ? errContent.slice(0, MAX_EMBED) : "",
+ });
+ }
+
+ emitLog(type: "LOG" | "LOGERR", message: string): void {
+ const depth = this.getFrameStack().length;
+ const indices = this.getAsyncIndices();
+ const liveBase: Record = { type, message, depth };
+ if (indices.length > 0) liveBase.async_indices = indices;
+ const payload = {
+ ...liveBase,
+ ts: nowIso(),
+ run_id: this.runId,
+ event_version: 1,
+ };
+ if (!this.suppressLiveEvents) {
+ process.stderr.write(`__JAIPH_EVENT__ ${JSON.stringify(liveBase)}\n`);
+ }
+ appendRunSummaryLine(JSON.stringify(payload));
+ }
+}
diff --git a/src/runtime/kernel/runtime-mock.ts b/src/runtime/kernel/runtime-mock.ts
new file mode 100644
index 00000000..fdd5db28
--- /dev/null
+++ b/src/runtime/kernel/runtime-mock.ts
@@ -0,0 +1,76 @@
+/**
+ * Mock-body execution for `*.test.jh` workflow/rule/script mocks.
+ *
+ * Shell-kind mocks run `bash -c` in the runtime's working directory with the
+ * mock's parameter names exposed as env vars. Steps-kind mocks dispatch back
+ * into the runtime via `executeStepsBack` so that the mock body runs against
+ * the runtime's full step interpreter.
+ */
+import { spawnSync } from "node:child_process";
+import type { WorkflowStepDef } from "../../types";
+
+/** Mock body definition: shell for script mocks, Jaiph steps for workflow/rule mocks. */
+export type MockBodyDef =
+ | { kind: "shell"; body: string; params: string[] }
+ | { kind: "steps"; steps: WorkflowStepDef[]; params: string[] };
+
+export type StepResult = {
+ status: number;
+ output: string;
+ error: string;
+ returnValue?: string;
+ /** Set when a catch body executed a `return` statement. */
+ recoverReturn?: boolean;
+};
+
+/**
+ * Execute a steps-kind mock body. Builds a fresh scope rooted at `entryFile`
+ * with `params`/`args` bound, then defers to the runtime's step executor.
+ */
+export type ExecuteStepsBack = (
+ params: string[],
+ args: string[],
+ steps: WorkflowStepDef[],
+) => Promise;
+
+export async function executeMockBodyDef(deps: {
+ ref: string;
+ mockDef: MockBodyDef;
+ args: string[];
+ env: NodeJS.ProcessEnv;
+ cwd: string;
+ executeStepsBack: ExecuteStepsBack;
+}): Promise {
+ const { mockDef, args, env, cwd, executeStepsBack } = deps;
+ if (mockDef.kind === "shell") {
+ return executeMockShellBody({ body: mockDef.body, args, params: mockDef.params, env, cwd });
+ }
+ return executeStepsBack(mockDef.params, args, mockDef.steps);
+}
+
+export function executeMockShellBody(deps: {
+ body: string;
+ args: string[];
+ params: string[];
+ env: NodeJS.ProcessEnv;
+ cwd: string;
+}): StepResult {
+ const { body, args, params, env, cwd } = deps;
+ const childEnv = { ...env };
+ params.forEach((name, i) => {
+ if (i < args.length) childEnv[name] = args[i];
+ });
+ const r = spawnSync("bash", ["-c", `set -euo pipefail\n${body}`, "mock", ...args], {
+ encoding: "utf8",
+ cwd,
+ env: childEnv,
+ });
+ const status = r.status ?? 1;
+ const output = r.stdout ?? "";
+ return {
+ status,
+ output,
+ error: r.stderr ?? "",
+ ...(status === 0 ? { returnValue: output.trim() } : {}),
+ };
+}
diff --git a/src/runtime/kernel/schema.test.ts b/src/runtime/kernel/schema.test.ts
index b3aeac33..c403e6de 100644
--- a/src/runtime/kernel/schema.test.ts
+++ b/src/runtime/kernel/schema.test.ts
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
-import { extractJson, validateFields, buildEvalString } from "./schema";
+import { extractJson, validateFields } from "./schema";
describe("extractJson", () => {
it("extracts from a plain JSON line", () => {
@@ -63,19 +63,3 @@ describe("validateFields", () => {
});
});
-describe("buildEvalString", () => {
- it("builds correct eval string", () => {
- const obj = { role: "engineer" };
- const fields = [{ name: "role", type: "string" }];
- const result = buildEvalString(obj, fields, "result", '{"role":"engineer"}');
- assert.ok(result.startsWith("result="));
- assert.ok(result.includes("export result_role='engineer'"));
- });
-
- it("escapes single quotes in values", () => {
- const obj = { note: "it's fine" };
- const fields = [{ name: "note", type: "string" }];
- const result = buildEvalString(obj, fields, "r", '{"note":"it\'s fine"}');
- assert.ok(result.includes("'\\''"));
- });
-});
diff --git a/src/runtime/kernel/schema.ts b/src/runtime/kernel/schema.ts
index 2a7c15bd..04199087 100644
--- a/src/runtime/kernel/schema.ts
+++ b/src/runtime/kernel/schema.ts
@@ -1,11 +1,4 @@
-// JS kernel: schema validation for typed prompts (returns '{ field: type }').
-// Schema validation for typed prompts.
-// Called from bash: echo "$raw" | node kernel/schema.js
-// Env: JAIPH_PROMPT_SCHEMA, JAIPH_PROMPT_CAPTURE_NAME
-// Stdout: eval-able shell string setting capture var + per-field exports.
-// Exit codes: 0=ok, 1=parse error, 2=missing field, 3=type mismatch.
-
-import { readFileSync } from "node:fs";
+// Schema validation for typed prompts (returns '{ field: type }').
type SchemaField = { name: string; type: string };
@@ -109,45 +102,3 @@ export function validateFields(
}
return 0;
}
-
-/** Build eval-able shell string: captureName='json' ; export captureName_field='value' ... */
-export function buildEvalString(
- obj: Record,
- fields: SchemaField[],
- captureName: string,
- source: string,
-): string {
- const esc = (s: string): string => String(s).replace(/'/g, "'\\''");
- let out = `${captureName}='${esc(source)}'`;
- for (const f of fields) {
- out += `; export ${captureName}_${f.name}='${esc(String(obj[f.name]))}'`;
- }
- return out;
-}
-
-// Main entry point when run as script
-function main(): void {
- const raw = readFileSync(0, "utf8");
- const schemaStr = process.env.JAIPH_PROMPT_SCHEMA || "";
- const captureName = process.env.JAIPH_PROMPT_CAPTURE_NAME || "result";
-
- if (!schemaStr) {
- process.stderr.write("jaiph: prompt_capture_with_schema: JAIPH_PROMPT_SCHEMA must be set\n");
- process.exit(1);
- }
-
- const schema = JSON.parse(schemaStr) as { fields?: SchemaField[] };
- const fields = (schema.fields || []).map((f) => ({ name: f.name, type: f.type }));
-
- const extracted = extractJson(raw);
- if (!extracted) process.exit(1);
-
- const validationResult = validateFields(extracted.obj, fields);
- if (validationResult !== 0) process.exit(validationResult);
-
- process.stdout.write(buildEvalString(extracted.obj, fields, captureName, extracted.source));
-}
-
-if (require.main === module) {
- main();
-}
diff --git a/src/runtime/kernel/seq-alloc.test.ts b/src/runtime/kernel/seq-alloc.test.ts
deleted file mode 100644
index 94c7f202..00000000
--- a/src/runtime/kernel/seq-alloc.test.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { allocateNextSeq } from "./seq-alloc";
-
-test("allocateNextSeq returns monotonic unique values", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-seq-"));
- try {
- writeFileSync(join(dir, ".seq"), "0");
- const seqs = [];
- for (let i = 0; i < 10; i++) {
- seqs.push(allocateNextSeq(dir));
- }
- assert.deepStrictEqual(seqs, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
- assert.equal(readFileSync(join(dir, ".seq"), "utf8"), "10");
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("allocateNextSeq starts from existing value", () => {
- const dir = mkdtempSync(join(tmpdir(), "jaiph-seq-"));
- try {
- writeFileSync(join(dir, ".seq"), "42");
- assert.equal(allocateNextSeq(dir), 43);
- assert.equal(allocateNextSeq(dir), 44);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
-});
diff --git a/src/runtime/kernel/seq-alloc.ts b/src/runtime/kernel/seq-alloc.ts
deleted file mode 100644
index a799d9a6..00000000
--- a/src/runtime/kernel/seq-alloc.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Atomic step-sequence allocator (JS kernel).
- * Single source of truth for seq allocation across all Bash async branches in a run.
- *
- * CLI: node seq-alloc.js
- * Env: JAIPH_RUN_DIR — run directory containing the .seq file.
- * Stdout: the allocated seq number (integer).
- */
-import { readFileSync, writeFileSync } from "node:fs";
-import { join, resolve } from "node:path";
-import { acquireLock, releaseLock } from "./fs-lock";
-
-export function allocateNextSeq(runDir: string): number {
- const seqFile = join(runDir, ".seq");
- const lockPath = `${seqFile}.lock`;
- if (!acquireLock(lockPath)) {
- throw new Error(`seq-alloc: lock timeout on ${lockPath}`);
- }
- try {
- const raw = readFileSync(seqFile, "utf8").trim();
- const current = raw ? parseInt(raw, 10) : 0;
- const next = current + 1;
- writeFileSync(seqFile, String(next));
- return next;
- } finally {
- releaseLock(lockPath);
- }
-}
-
-function main(): void {
- const runDir = process.env.JAIPH_RUN_DIR;
- if (!runDir) {
- process.stderr.write("seq-alloc: JAIPH_RUN_DIR required\n");
- process.exit(1);
- }
- process.stdout.write(String(allocateNextSeq(runDir)));
-}
-
-if (resolve(process.argv[1] ?? "") === resolve(__filename)) {
- main();
-}
diff --git a/src/runtime/kernel/stream-parser.ts b/src/runtime/kernel/stream-parser.ts
index 00b83f68..1e200b99 100644
--- a/src/runtime/kernel/stream-parser.ts
+++ b/src/runtime/kernel/stream-parser.ts
@@ -1,9 +1,7 @@
// Stream JSON parser: converts streaming backend output (cursor-agent / claude CLI)
// into sectioned text (Reasoning + Final answer) and extracts the final response.
-// Standalone entry point: echo events | node kernel/stream-parser.js
import { createInterface, type Interface } from "node:readline";
-import { writeFileSync } from "node:fs";
import type { Readable } from "node:stream";
export type StreamState = {
@@ -246,16 +244,3 @@ export function parseStream(
});
}
-// CLI entry point: reads stdin, writes to stdout, saves final to JAIPH_PROMPT_FINAL_FILE.
-if (require.main === module) {
- const writer: StreamWriter = {
- writeReasoning: (t) => { process.stdout.write(t); },
- writeFinal: (t) => { process.stdout.write(t); },
- };
- parseStream(process.stdin, writer).then((final) => {
- const finalPath = process.env.JAIPH_PROMPT_FINAL_FILE;
- if (typeof finalPath === "string" && finalPath.length > 0) {
- try { writeFileSync(finalPath, final, "utf8"); } catch { /* best-effort */ }
- }
- });
-}
diff --git a/src/transpile/build.test.ts b/src/transpile/build.test.ts
new file mode 100644
index 00000000..ae63e51f
--- /dev/null
+++ b/src/transpile/build.test.ts
@@ -0,0 +1,28 @@
+import { describe, it } from "node:test";
+import * as assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { walkjhFiles } from "./build";
+
+describe("walkjhFiles", () => {
+ it("ignores generated .jaiph runtime directories", () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-walk-"));
+ try {
+ mkdirSync(join(root, ".jaiph", "runs", ".sandbox", "e2e"), { recursive: true });
+ mkdirSync(join(root, ".jaiph", "tmp"), { recursive: true });
+ mkdirSync(join(root, ".jaiph", "artifacts"), { recursive: true });
+ mkdirSync(join(root, ".jaiph", "src"), { recursive: true });
+
+ const source = join(root, ".jaiph", "src", "workflow.jh");
+ writeFileSync(source, "workflow default() {\n}\n");
+ writeFileSync(join(root, ".jaiph", "runs", ".sandbox", "e2e", "old.jh"), "workflow stale() {\n}\n");
+ writeFileSync(join(root, ".jaiph", "tmp", "scratch.jh"), "workflow scratch() {\n}\n");
+ writeFileSync(join(root, ".jaiph", "artifacts", "patch.jh"), "workflow patch() {\n}\n");
+
+ assert.deepEqual(walkjhFiles(root), [source]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/transpile/build.ts b/src/transpile/build.ts
index c0988929..cbe4d478 100644
--- a/src/transpile/build.ts
+++ b/src/transpile/build.ts
@@ -19,12 +19,14 @@ export function walkjhFiles(inputPath: string): string[] {
}
const files: string[] = [];
+ const rootDir = resolve(inputPath);
const stack = [inputPath];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const full = join(current, entry.name);
if (entry.isDirectory()) {
+ if (shouldSkipJhWalkDirectory(rootDir, full)) continue;
stack.push(full);
} else if (entry.isFile()) {
const ext = extname(entry.name);
@@ -39,6 +41,29 @@ export function walkjhFiles(inputPath: string): string[] {
return files;
}
+function shouldSkipJhWalkDirectory(rootDir: string, directory: string): boolean {
+ const rel = relative(rootDir, directory).split("/").join("/");
+ const rootBase = parse(rootDir).base;
+ if (rootBase === ".jaiph" && (
+ rel === "runs" ||
+ rel === "tmp" ||
+ rel === "artifacts" ||
+ rel === ".tmp-build-out"
+ )) {
+ return true;
+ }
+ return (
+ rel === ".jaiph/runs" ||
+ rel.startsWith(".jaiph/runs/") ||
+ rel === ".jaiph/tmp" ||
+ rel.startsWith(".jaiph/tmp/") ||
+ rel === ".jaiph/artifacts" ||
+ rel.startsWith(".jaiph/artifacts/") ||
+ rel === ".jaiph/.tmp-build-out" ||
+ rel.startsWith(".jaiph/.tmp-build-out/")
+ );
+}
+
export function walkTestFiles(inputPath: string): string[] {
const s = statSync(inputPath);
if (s.isFile()) {
diff --git a/src/transpile/compiler-edge.acceptance.test.ts b/src/transpile/compiler-edge.acceptance.test.ts
index 5d031b07..ca99a578 100644
--- a/src/transpile/compiler-edge.acceptance.test.ts
+++ b/src/transpile/compiler-edge.acceptance.test.ts
@@ -320,7 +320,7 @@ test("ACCEPTANCE: rule with multi-line || { ... } fails under strict shell-step
});
});
-test("ACCEPTANCE: workflow shell step with || { ... } fails under strict shell-step ban", () => {
+test("ACCEPTANCE: workflow shell step with || { ... } is allowed and compiles", () => {
withTempDir("jaiph-acc-workflow-or-brace-", (root) => {
writeFileSync(
join(root, "main.jh"),
@@ -331,14 +331,11 @@ test("ACCEPTANCE: workflow shell step with || { ... } fails under strict shell-s
"",
].join("\n"),
);
- assert.throws(
- () => buildScripts(join(root, "main.jh"), join(root, "out")),
- /E_VALIDATE inline shell steps are forbidden in workflows; use explicit script blocks/,
- );
+ buildScripts(join(root, "main.jh"), join(root, "out"));
});
});
-test("ACCEPTANCE: inline shell short-circuit fails under strict shell-step ban", () => {
+test("ACCEPTANCE: inline shell short-circuit in workflow compiles", () => {
withTempDir("jaiph-acc-or-brace-workflow-", (root) => {
writeFileSync(
join(root, "main.jh"),
@@ -351,10 +348,7 @@ test("ACCEPTANCE: inline shell short-circuit fails under strict shell-step ban",
"",
].join("\n"),
);
- assert.throws(
- () => buildScripts(join(root, "main.jh"), join(root, "out")),
- /E_VALIDATE inline shell steps are forbidden in workflows; use explicit script blocks/,
- );
+ buildScripts(join(root, "main.jh"), join(root, "out"));
});
});
@@ -669,9 +663,9 @@ test("ACCEPTANCE: capture + send is parse error", () => {
"",
].join("\n"),
);
- // "name = channel <- echo hello" is treated as an inline shell step,
- // which is rejected by the inline-shell validation pass.
- assert.throws(() => buildScripts(root, join(root, "out")), /inline shell steps are forbidden/);
+ // "name = channel <- echo hello" parses as a shell line with `<-` that
+ // is not a well-formed `channel <- rhs` send.
+ assert.throws(() => buildScripts(root, join(root, "out")), /invalid send: channel must be a single name or/);
});
});
diff --git a/src/transpile/compiler-golden.test.ts b/src/transpile/compiler-golden.test.ts
index 99e52b5e..b4c78c74 100644
--- a/src/transpile/compiler-golden.test.ts
+++ b/src/transpile/compiler-golden.test.ts
@@ -70,7 +70,7 @@ test("compiler golden: prompt substitution guard reports E_PARSE", () => {
test("compiler corpus: fixtures compile", () => {
const outA = mkdtempSync(join(tmpdir(), "jaiph-corpus-a-"));
try {
- buildScripts(join(process.cwd(), "test/fixtures"), outA);
+ buildScripts(join(process.cwd(), "test-fixtures/sample-build/fixtures"), outA);
} finally {
rmSync(outA, { recursive: true, force: true });
}
@@ -279,39 +279,6 @@ test("parser: config integer key rejects string value with E_PARSE", () => {
);
});
-test("parser: runtime.workspace produces E_PARSE (no longer supported)", () => {
- const source = [
- "config {",
- " runtime.workspace = [",
- ' ".:/jaiph/workspace:rw",',
- ' "config:config:ro"',
- " ]",
- "}",
- "workflow default() {",
- " log \"ok\"",
- "}",
- ].join("\n");
- assert.throws(
- () => parsejaiph(source, "/fake/entry.jh"),
- /runtime\.workspace is no longer supported/,
- );
-});
-
-test("parser: runtime.workspace with scalar value also produces E_PARSE", () => {
- const source = [
- "config {",
- ' runtime.workspace = "not-an-array"',
- "}",
- "workflow default() {",
- " log \"ok\"",
- "}",
- ].join("\n");
- assert.throws(
- () => parsejaiph(source, "/fake/entry.jh"),
- /runtime\.workspace is no longer supported/,
- );
-});
-
test("parser: all runtime config keys are accepted (docker_enabled removed)", () => {
const source = [
"config {",
@@ -330,21 +297,6 @@ test("parser: all runtime config keys are accepted (docker_enabled removed)", ()
assert.strictEqual(mod.metadata!.runtime!.dockerTimeoutSeconds, 600);
});
-test("parser: runtime.docker_enabled produces E_PARSE with helpful message", () => {
- const source = [
- "config {",
- " runtime.docker_enabled = true",
- "}",
- "workflow default() {",
- " log \"ok\"",
- "}",
- ].join("\n");
- assert.throws(
- () => parsejaiph(source, "/fake/entry.jh"),
- /runtime\.docker_enabled is no longer supported/,
- );
-});
-
test("parser: unknown runtime key throws E_PARSE", () => {
const source = [
"config {",
@@ -393,9 +345,9 @@ test("parser: run ... catch parses correctly", () => {
const step = mod.workflows[0].steps[0];
assert.equal(step.type, "run");
if (step.type === "run") {
- assert.ok(step.recover);
- assert.equal(step.recover!.bindings.failure, "err");
- const recoverSteps = "block" in step.recover! ? step.recover!.block : [step.recover!.single];
+ assert.ok(step.catch);
+ assert.equal(step.catch!.bindings.failure, "err");
+ const recoverSteps = "block" in step.catch! ? step.catch!.block : [step.catch!.single];
assert.equal(recoverSteps.length, 1);
assert.equal(recoverSteps[0].type, "log");
}
@@ -759,19 +711,6 @@ test("parser: top-level const declaration parses bare value", () => {
assert.equal(mod.envDecls![0].value, "42");
});
-test("parser: top-level local keyword is rejected", () => {
- const source = [
- 'local greeting = "hello world"',
- "workflow default() {",
- " log \"${greeting}\"",
- "}",
- ].join("\n");
- assert.throws(
- () => parsejaiph(source, "/fake/entry.jh"),
- /unknown top-level keyword "local" — use const NAME = VALUE/,
- );
-});
-
test("parser: top-level const name collision with rule is E_PARSE", () => {
const source = [
'const foo = "bar"',
diff --git a/src/transpile/emit-script.ts b/src/transpile/emit-script.ts
index ea5792ce..ca8c9185 100644
--- a/src/transpile/emit-script.ts
+++ b/src/transpile/emit-script.ts
@@ -88,8 +88,8 @@ function collectInlineScripts(
} else if ((s.type === "log" || s.type === "logerr") && s.managed?.kind === "run_inline_script") {
const shebang = s.managed.lang ? langToShebang(s.managed.lang) : undefined;
emitInlineScriptArtifact(s.managed.body, shebang, seen, out);
- } else if ((s.type === "ensure" || s.type === "run") && s.recover) {
- const recoverSteps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ } else if ((s.type === "ensure" || s.type === "run") && s.catch) {
+ const recoverSteps = "single" in s.catch ? [s.catch.single] : s.catch.block;
collectInlineScripts(recoverSteps, seen, out);
}
}
diff --git a/src/transpile/validate-managed-calls.test.ts b/src/transpile/validate-managed-calls.test.ts
index baf91ec6..c1ab3230 100644
--- a/src/transpile/validate-managed-calls.test.ts
+++ b/src/transpile/validate-managed-calls.test.ts
@@ -5,8 +5,9 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { buildScripts } from "../transpiler";
-test("E_VALIDATE: inline shell step is forbidden in workflow", () => {
+test("buildScripts accepts subshell capture in workflow shell line", () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-val-sub-fn-"));
+ const out = join(root, "out");
try {
writeFileSync(
join(root, "m.jh"),
@@ -18,16 +19,13 @@ test("E_VALIDATE: inline shell step is forbidden in workflow", () => {
"",
].join("\n"),
);
- assert.throws(
- () => buildScripts(join(root, "m.jh"), join(root, "out")),
- /inline shell steps are forbidden in workflows; use explicit script blocks/,
- );
+ buildScripts(join(root, "m.jh"), out);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
-test("E_VALIDATE: direct inline shell step is forbidden in workflow", () => {
+test("E_VALIDATE: bare script name as raw shell line must use run", () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-val-direct-fn-"));
try {
writeFileSync(
@@ -42,7 +40,7 @@ test("E_VALIDATE: direct inline shell step is forbidden in workflow", () => {
);
assert.throws(
() => buildScripts(join(root, "m.jh"), join(root, "out")),
- /inline shell steps are forbidden in workflows; use explicit script blocks/,
+ /use run f/,
);
} finally {
rmSync(root, { recursive: true, force: true });
@@ -93,7 +91,7 @@ test("buildScripts extracts script for run with capture workflow", () => {
}
});
-test("E_VALIDATE: inline shell line with workflow ref is forbidden", () => {
+test("E_VALIDATE: bare workflow name as raw shell line must use run", () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-val-wf-plus-sub-"));
try {
writeFileSync(
@@ -104,14 +102,14 @@ test("E_VALIDATE: inline shell line with workflow ref is forbidden", () => {
" run w_impl()",
"}",
"workflow default() {",
- " w $(true)",
+ " w",
"}",
"",
].join("\n"),
);
assert.throws(
() => buildScripts(join(root, "m.jh"), join(root, "out")),
- /inline shell steps are forbidden in workflows; use explicit script blocks/,
+ /use run w/,
);
} finally {
rmSync(root, { recursive: true, force: true });
diff --git a/src/transpile/validate.ts b/src/transpile/validate.ts
index e4465d52..b537a683 100644
--- a/src/transpile/validate.ts
+++ b/src/transpile/validate.ts
@@ -25,6 +25,7 @@ import {
} from "./validate-string";
import { validatePromptReturnsSchema, validatePromptStepReturns } from "./validate-prompt-schema";
import { dedentCommonLeadingWhitespace } from "../parse/dedent";
+import { matchSendOperator } from "../parse/core";
import { tripleQuotedRawForRuntime } from "../runtime/orchestration-text";
export interface ValidateContext {
@@ -35,6 +36,31 @@ export interface ValidateContext {
workspaceRoot?: string;
}
+/** True when `<-` appears outside quotes (same idea as `matchSendOperator`). */
+function hasUnquotedSendArrow(line: string): boolean {
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+ for (let i = 0; i < line.length; i += 1) {
+ const ch = line[i];
+ if (ch === "\\" && (inDoubleQuote || inSingleQuote)) {
+ i += 1;
+ continue;
+ }
+ if (ch === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+ if (ch === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+ if (!inSingleQuote && !inDoubleQuote && ch === "<" && line[i + 1] === "-") {
+ return true;
+ }
+ }
+ return false;
+}
+
/** Check if args contain unquoted shell redirection operators (>, >>, |, &). */
function hasShellRedirection(args: string): boolean {
let inQuote = false;
@@ -173,8 +199,8 @@ function collectKnownVars(steps: WorkflowStepDef[], envDecls?: { name: string }[
if ((s.type === "ensure" || s.type === "run" || s.type === "prompt" || s.type === "run_inline_script") && s.captureName) {
vars.add(s.captureName);
}
- if ((s.type === "ensure" || s.type === "run") && s.recover) {
- const recoverSteps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if ((s.type === "ensure" || s.type === "run") && s.catch) {
+ const recoverSteps = "single" in s.catch ? [s.catch.single] : s.catch.block;
walk(recoverSteps);
}
if (s.type === "if") {
@@ -238,8 +264,8 @@ function validateImmutableBindings(
if ((s.type === "prompt" || s.type === "run_inline_script") && s.captureName) {
check(s.captureName, "capture", s.loc);
}
- if ((s.type === "ensure" || s.type === "run") && s.recover) {
- const recoverSteps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if ((s.type === "ensure" || s.type === "run") && s.catch) {
+ const recoverSteps = "single" in s.catch ? [s.catch.single] : s.catch.block;
walk(recoverSteps);
}
if (s.type === "if") {
@@ -536,6 +562,18 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
const stripDQ = (s: string): string =>
s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"' ? s.slice(1, -1) : s;
+ /**
+ * Detect `const x = scriptName` and its parser sugar form `const x = "${scriptName}"`.
+ * Both should report the same domain error ("scripts are not values").
+ */
+ const extractConstScriptName = (rhs: string): string | undefined => {
+ const trimmed = rhs.trim();
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed;
+ const inner = stripDQ(trimmed);
+ const m = inner.match(/^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/);
+ return m?.[1];
+ };
+
/** Inner string for validation: same margin removal as runtime for `"""` orchestration text. */
const semanticQuotedOrchestrationInner = (dqRaw: string, tripleQuoted: boolean): string => {
if (!tripleQuoted) return stripDQ(dqRaw);
@@ -645,10 +683,10 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
validateArity(ast.filePath, s.ref.loc, s.ref.value, s.args, "rule", ast, refCtx);
validateBareIdentifierArgs(ast.filePath, s.ref.loc, s.bareIdentifierArgs, ruleKnownVars);
- if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
const rb = new Set();
- rb.add(s.recover.bindings.failure);
+ rb.add(s.catch.bindings.failure);
for (const r of steps) validateRuleStep(r);
}
return;
@@ -672,16 +710,16 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
validateArity(ast.filePath, s.workflow.loc, s.workflow.value, s.args, "workflow", ast, refCtx);
validateBareIdentifierArgs(ast.filePath, s.workflow.loc, s.bareIdentifierArgs, ruleKnownVars);
- if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
const rb = new Set();
- rb.add(s.recover.bindings.failure);
+ rb.add(s.catch.bindings.failure);
for (const r of steps) validateRuleStep(r);
}
- if (s.recoverLoop) {
- const steps = "single" in s.recoverLoop ? [s.recoverLoop.single] : s.recoverLoop.block;
+ if (s.recover) {
+ const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
const rb = new Set();
- rb.add(s.recoverLoop.bindings.failure);
+ rb.add(s.recover.bindings.failure);
for (const r of steps) validateRuleStep(r);
}
return;
@@ -811,9 +849,9 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
} else if (v.kind === "match_expr") {
validateMatchExpr(ast.filePath, v.match, ruleKnownVars);
} else if (v.kind === "expr") {
- const bareRhs = v.bashRhs.trim();
- if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(bareRhs) && localScripts.has(bareRhs)) {
- throw jaiphError(ast.filePath, s.loc.line, s.loc.col, "E_VALIDATE", `scripts are not values; "${bareRhs}" is a script definition`);
+ const scriptName = extractConstScriptName(v.bashRhs);
+ if (scriptName && localScripts.has(scriptName)) {
+ throw jaiphError(ast.filePath, s.loc.line, s.loc.col, "E_VALIDATE", `scripts are not values; "${scriptName}" is a script definition`);
}
validateRuleStringCaptures(stripDQ(v.bashRhs), s.loc);
validateSimpleInterpolationIdentifiers(
@@ -987,10 +1025,10 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
validateArity(ast.filePath, s.ref.loc, s.ref.value, s.args, "rule", ast, refCtx);
validateBareIdentifierArgs(ast.filePath, s.ref.loc, s.bareIdentifierArgs, wfKnownVars, recoverBindings);
- if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
const rb = new Set();
- rb.add(s.recover.bindings.failure);
+ rb.add(s.catch.bindings.failure);
for (const r of steps) validateStep(r, rb);
}
return;
@@ -1005,16 +1043,16 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
validateArity(ast.filePath, s.workflow.loc, s.workflow.value, s.args, "workflow", ast, refCtx);
validateBareIdentifierArgs(ast.filePath, s.workflow.loc, s.bareIdentifierArgs, wfKnownVars, recoverBindings);
- if (s.recover) {
- const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
+ if (s.catch) {
+ const steps = "single" in s.catch ? [s.catch.single] : s.catch.block;
const rb = new Set();
- rb.add(s.recover.bindings.failure);
+ rb.add(s.catch.bindings.failure);
for (const r of steps) validateStep(r, rb);
}
- if (s.recoverLoop) {
- const steps = "single" in s.recoverLoop ? [s.recoverLoop.single] : s.recoverLoop.block;
+ if (s.recover) {
+ const steps = "single" in s.recover ? [s.recover.single] : s.recover.block;
const rb = new Set();
- rb.add(s.recoverLoop.bindings.failure);
+ rb.add(s.recover.bindings.failure);
for (const r of steps) validateStep(r, rb);
}
return;
@@ -1197,9 +1235,9 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
} else if (v.kind === "match_expr") {
validateMatchExpr(ast.filePath, v.match, wfKnownVars);
} else if (v.kind === "expr") {
- const bareRhs = v.bashRhs.trim();
- if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(bareRhs) && localScripts.has(bareRhs)) {
- throw jaiphError(ast.filePath, s.loc.line, s.loc.col, "E_VALIDATE", `scripts are not values; "${bareRhs}" is a script definition`);
+ const scriptName = extractConstScriptName(v.bashRhs);
+ if (scriptName && localScripts.has(scriptName)) {
+ throw jaiphError(ast.filePath, s.loc.line, s.loc.col, "E_VALIDATE", `scripts are not values; "${scriptName}" is a script definition`);
}
const exprInner = semanticQuotedOrchestrationInner(v.bashRhs, v.tripleQuoted === true);
validateWorkflowStringCaptures(exprInner, s.loc);
@@ -1236,13 +1274,39 @@ export function validateReferences(ast: jaiphModule, ctx: ValidateContext): void
return;
}
if (s.type === "shell") {
- throw jaiphError(
- ast.filePath,
- s.loc.line,
- s.loc.col,
- "E_VALIDATE",
- "inline shell steps are forbidden in workflows; use explicit script blocks",
- );
+ if (hasUnquotedSendArrow(s.command) && matchSendOperator(s.command) === null) {
+ throw jaiphError(
+ ast.filePath,
+ s.loc.line,
+ s.loc.col,
+ "E_VALIDATE",
+ "invalid send: channel must be a single name or `alias.name` (at most one dot in the channel part)",
+ );
+ }
+ const t = s.command.trim();
+ if (/^(?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(t)) {
+ if (!t.includes(".")) {
+ if (localScripts.has(t) || localWorkflows.has(t)) {
+ throw jaiphError(
+ ast.filePath,
+ s.loc.line,
+ s.loc.col,
+ "E_VALIDATE",
+ `use run ${t}() — a bare name that refers to a script or workflow must use a managed run step`,
+ );
+ }
+ } else {
+ validateRef({ value: t, loc: s.loc }, ast, refCtx, expectRunTargetRef);
+ throw jaiphError(
+ ast.filePath,
+ s.loc.line,
+ s.loc.col,
+ "E_VALIDATE",
+ `use run ${t}() — "${t}" is a valid script or workflow reference; use a managed run step`,
+ );
+ }
+ }
+ return;
}
const _never: never = s;
return _never;
diff --git a/src/types.ts b/src/types.ts
index 0ed58920..ae4b4d98 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -128,7 +128,7 @@ export type WorkflowStepDef =
/** When set, capture step stdout into this variable name. */
captureName?: string;
/** When set, catch failure and run recovery body once. */
- recover?:
+ catch?:
| { single: WorkflowStepDef; bindings: { failure: string } }
| { block: WorkflowStepDef[]; bindings: { failure: string } };
}
@@ -142,11 +142,11 @@ export type WorkflowStepDef =
/** When set, execute asynchronously with implicit join before workflow completes. */
async?: boolean;
/** When set, catch failure and run recovery body once. */
- recover?:
+ catch?:
| { single: WorkflowStepDef; bindings: { failure: string } }
| { block: WorkflowStepDef[]; bindings: { failure: string } };
/** When set, retry with repair loop semantics (try → fail → recover body → retry). */
- recoverLoop?:
+ recover?:
| { single: WorkflowStepDef; bindings: { failure: string } }
| { block: WorkflowStepDef[]; bindings: { failure: string } };
}
@@ -318,7 +318,7 @@ export interface WorkflowMetadata {
cursorFlags?: string;
claudeFlags?: string;
};
- run?: { debug?: boolean; logsDir?: string; inboxParallel?: boolean; recoverLimit?: number };
+ run?: { debug?: boolean; logsDir?: string; recoverLimit?: number };
runtime?: RuntimeConfig;
module?: { name?: string; version?: string; description?: string };
/** Preserves `#` lines and assignment order inside `config { }` (formatter). */
diff --git a/compiler-tests/README.md b/test-fixtures/compiler-txtar/README.md
similarity index 100%
rename from compiler-tests/README.md
rename to test-fixtures/compiler-txtar/README.md
diff --git a/compiler-tests/parse-errors.txt b/test-fixtures/compiler-txtar/parse-errors.txt
similarity index 94%
rename from compiler-tests/parse-errors.txt
rename to test-fixtures/compiler-txtar/parse-errors.txt
index f157648c..35f01712 100644
--- a/compiler-tests/parse-errors.txt
+++ b/test-fixtures/compiler-txtar/parse-errors.txt
@@ -1,5 +1,5 @@
=== unterminated workflow block
-# @expect error E_PARSE "unterminated workflow block" @1:1
+# @expect error E_PARSE "unterminated block" @1:1
--- input.jh
workflow default() {
log "hello"
@@ -120,7 +120,7 @@ script greet()
workflow default
=== workflow with parentheses (unterminated)
-# @expect error E_PARSE "unterminated workflow block" @1:1
+# @expect error E_PARSE "unterminated block" @1:1
--- input.jh
workflow default() {
@@ -174,12 +174,12 @@ console.log('hi');
```
=== script tag with parentheses
-# @expect error E_PARSE "script:lang syntax is no longer supported" @1:1
+# @expect error E_PARSE "unsupported top-level statement" @1:1
--- input.jh
script:node transform() {
=== script tag without braces
-# @expect error E_PARSE "script:lang syntax is no longer supported" @1:1
+# @expect error E_PARSE "unsupported top-level statement" @1:1
--- input.jh
script:node transform
@@ -235,7 +235,7 @@ workflow default() {
}
=== config array key rejects runtime.workspace (no longer supported)
-# @expect error E_PARSE "runtime.workspace is no longer supported" @2:3
+# @expect error E_PARSE "unknown config key: runtime.workspace" @2:3
--- input.jh
config {
runtime.workspace = "not-an-array"
@@ -245,7 +245,7 @@ workflow default() {
}
=== config rejects runtime.docker_enabled (no longer supported)
-# @expect error E_PARSE "runtime.docker_enabled is no longer supported" @2:3
+# @expect error E_PARSE "unknown config key: runtime.docker_enabled" @2:3
--- input.jh
config {
runtime.docker_enabled = true
@@ -282,14 +282,14 @@ workflow default() {
}
=== capture and send cannot be combined
-# @expect error E_VALIDATE "inline shell steps are forbidden" @2:3
+# @expect error E_VALIDATE "invalid send: channel must be a single name or" @2:3
--- input.jh
workflow default() {
name = channel <- "hello"
}
=== top-level local keyword is rejected
-# @expect error E_PARSE "unknown top-level keyword" @1:1
+# @expect error E_PARSE "unsupported top-level statement" @1:1
--- input.jh
local greeting = "hello world"
workflow default() {
@@ -430,7 +430,7 @@ workflow default() {
}
=== capture and send combined alt form
-# @expect error E_VALIDATE "inline shell steps are forbidden" @2:3
+# @expect error E_VALIDATE "invalid send: channel must be a single name or" @2:3
--- input.jh
workflow default() {
name = channel <- echo hello
@@ -780,7 +780,7 @@ script broken = ```
echo hello
=== metadata: runtime.workspace array rejected (single-quoted element)
-# @expect error E_PARSE "runtime.workspace is no longer supported" @2:3
+# @expect error E_PARSE "unknown config key: runtime.workspace" @2:3
--- input.jh
config {
runtime.workspace = [
@@ -792,7 +792,7 @@ workflow default() {
}
=== metadata: runtime.workspace array rejected (unquoted element)
-# @expect error E_PARSE "runtime.workspace is no longer supported" @2:3
+# @expect error E_PARSE "unknown config key: runtime.workspace" @2:3
--- input.jh
config {
runtime.workspace = [
@@ -804,7 +804,7 @@ workflow default() {
}
=== metadata: runtime.workspace array rejected (unclosed)
-# @expect error E_PARSE "runtime.workspace is no longer supported" @2:3
+# @expect error E_PARSE "unknown config key: runtime.workspace" @2:3
--- input.jh
config {
runtime.workspace = [
@@ -1094,7 +1094,7 @@ workflow default() {
=== config block with content on same line as opening
-# @expect ok
+# @expect error E_PARSE "config block must be exactly" @1:1
--- input.jh
config { agent.backend = "claude" }
workflow default() {
@@ -1173,7 +1173,7 @@ workflow default() {
}
=== capture and send combined in workflow body
-# @expect error E_VALIDATE "inline shell steps are forbidden" @3:3
+# @expect error E_VALIDATE "invalid send: channel must be a single name or" @3:3
--- input.jh
channel findings
workflow default() {
@@ -1242,7 +1242,7 @@ workflow default() {
}
=== script with returns on closing fence rejected
-# @expect error E_PARSE "script definitions do not support" @1:1
+# @expect error E_PARSE "unexpected content after closing fence" @1:1
--- input.jh
script transform = ```
echo hello
@@ -1295,7 +1295,7 @@ console.log("hi");
}
=== inline script fenced unterminated in workflow
-# @expect error E_PARSE "unterminated fenced block" @2:3
+# @expect error E_PARSE "unterminated fenced block" @2:1
--- input.jh
workflow default() {
run ```
@@ -1310,7 +1310,7 @@ workflow default() {
}
=== inline script fenced with invalid lang token
-# @expect error E_PARSE "invalid opening fence" @2:3
+# @expect error E_PARSE "invalid opening fence" @2:1
--- input.jh
workflow default() {
run ```not a lang```()
@@ -1323,7 +1323,7 @@ config {
agent.backend = "claude"
=== config block with trailing content after close brace
-# @expect error E_PARSE "unexpected content after closing '}'" @1:1
+# @expect error E_PARSE "config block must be exactly" @1:1
--- input.jh
config { agent.backend = "claude" } extra
workflow default() {
@@ -1909,7 +1909,7 @@ workflow default() {
}
=== inline config block missing equals sign
-# @expect error E_PARSE "config line must be key = value"
+# @expect error E_PARSE "config block must be exactly"
--- input.jh
config { agent.backend }
workflow default() {
@@ -1917,7 +1917,7 @@ workflow default() {
}
=== inline config block with unknown key
-# @expect error E_PARSE "unknown config key"
+# @expect error E_PARSE "config block must be exactly"
--- input.jh
config { bad.key = "x" }
workflow default() {
@@ -1925,7 +1925,7 @@ workflow default() {
}
=== inline config block rejects runtime.workspace (array opening)
-# @expect error E_PARSE "runtime.workspace is no longer supported"
+# @expect error E_PARSE "config block must be exactly"
--- input.jh
config { runtime.workspace = [ }
workflow default() {
@@ -1933,7 +1933,7 @@ workflow default() {
}
=== inline config block rejects runtime.workspace (non-empty array)
-# @expect error E_PARSE "runtime.workspace is no longer supported"
+# @expect error E_PARSE "config block must be exactly"
--- input.jh
config { runtime.workspace = ["foo"] }
workflow default() {
@@ -1967,7 +1967,7 @@ workflow default() { log "hello"
}
=== runtime keys in inline workflow config
-# @expect error E_PARSE "runtime.* keys are not allowed"
+# @expect error E_PARSE "expected newline after '{'"
--- input.jh
workflow default() { config { runtime.docker_image = "ubuntu:24.04" } }
@@ -2064,7 +2064,7 @@ workflow default() {
}
=== config after semicolon-separated steps in workflow
-# @expect error E_PARSE "config must be the first workflow step"
+# @expect error E_PARSE "unexpected content after log string"
--- input.jh
workflow default() {
log "hello"; config { agent.backend = "claude" }
@@ -2458,3 +2458,67 @@ workflow default() {
c <- run helper()
}
+=== return with bash exit code rejected in workflow
+# @expect error E_PARSE "bash exit codes are only valid in scripts" @2:3
+--- input.jh
+workflow default() {
+ return 0
+}
+
+=== return with bash dollar-question rejected in workflow
+# @expect error E_PARSE "bash exit codes are only valid in scripts" @2:3
+--- input.jh
+workflow default() {
+ return $?
+}
+
+=== if equality operator with regex operand rejected
+# @expect error E_PARSE "requires a string operand" @2:3
+--- input.jh
+workflow default(x) {
+ if x == /pattern/ {
+ log "match"
+ }
+}
+
+=== if inequality operator with regex operand rejected
+# @expect error E_PARSE "requires a string operand" @2:3
+--- input.jh
+workflow default(x) {
+ if x != /pattern/ {
+ log "match"
+ }
+}
+
+=== if regex-match operator with string operand rejected
+# @expect error E_PARSE "requires a regex operand" @2:3
+--- input.jh
+workflow default(x) {
+ if x =~ "literal" {
+ log "match"
+ }
+}
+
+=== if negative regex-match operator with string operand rejected
+# @expect error E_PARSE "requires a regex operand" @2:3
+--- input.jh
+workflow default(x) {
+ if x !~ "literal" {
+ log "match"
+ }
+}
+
+=== const run async with inline script rejected
+# @expect error E_PARSE "run async is not supported with inline scripts" @2:13
+--- input.jh
+workflow default() {
+ const x = run async `echo hello`()
+}
+
+=== const run async with invalid reference
+# @expect error E_PARSE "const ... = run async must target a valid reference" @2:13
+--- input.jh
+workflow default() {
+ const x = run async 123bad()
+}
+
diff --git a/compiler-tests/valid.txt b/test-fixtures/compiler-txtar/valid.txt
similarity index 100%
rename from compiler-tests/valid.txt
rename to test-fixtures/compiler-txtar/valid.txt
diff --git a/compiler-tests/validate-errors-multi-module.txt b/test-fixtures/compiler-txtar/validate-errors-multi-module.txt
similarity index 98%
rename from compiler-tests/validate-errors-multi-module.txt
rename to test-fixtures/compiler-txtar/validate-errors-multi-module.txt
index adbc5073..cbfa9ac2 100644
--- a/compiler-tests/validate-errors-multi-module.txt
+++ b/test-fixtures/compiler-txtar/validate-errors-multi-module.txt
@@ -187,7 +187,7 @@ workflow private_wf() {
}
=== shell line with unknown imported symbol in workflow
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows"
+# @expect error E_VALIDATE "imported workflow or script"
--- main.jh
import "lib.jh" as lib
workflow default() {
diff --git a/compiler-tests/validate-errors.txt b/test-fixtures/compiler-txtar/validate-errors.txt
similarity index 91%
rename from compiler-tests/validate-errors.txt
rename to test-fixtures/compiler-txtar/validate-errors.txt
index 31b8656d..1fc4d9d8 100644
--- a/compiler-tests/validate-errors.txt
+++ b/test-fixtures/compiler-txtar/validate-errors.txt
@@ -47,21 +47,6 @@ workflow default() {
ensure example()
}
-=== workflow shell step with brace group fails shell-step ban
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows" @2:3
---- input.jh
-workflow default() {
- cmd || { echo "failed"; exit 1; }
-}
-
-=== inline shell short-circuit fails shell-step ban
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows" @3:3
---- input.jh
-script gate_impl = `true`
-workflow default() {
- other || { echo "err"; exit 1; }
-}
-
=== unsupported type in returns schema
# @expect error E_SCHEMA "unsupported type" @2:3
--- input.jh
@@ -69,31 +54,23 @@ workflow default() {
const result = prompt "x" returns "{ foo: array }"
}
-=== inline shell step subshell capture forbidden in workflow
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows" @3:3
---- input.jh
-script f = `printf '%s' 'x'`
-workflow default() {
- x="$(f)"
-}
-
-=== direct inline shell step forbidden in workflow
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows" @3:3
+=== workflow raw shell that names a script must use run
+# @expect error E_VALIDATE "use run f()" @3:3
--- input.jh
script f = `printf '%s' 'x'`
workflow default() {
f
}
-=== inline shell line with workflow ref is forbidden
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows" @6:3
+=== workflow raw shell that names a workflow must use run
+# @expect error E_VALIDATE "use run w()" @6:3
--- input.jh
script w_impl = `echo x`
workflow w() {
run w_impl()
}
workflow default() {
- w $(true)
+ w
}
=== send RHS cannot invoke workflow via shell
@@ -530,7 +507,7 @@ workflow default() {
}
=== channel reference with three dot parts is rejected
-# @expect error E_VALIDATE "inline shell steps are forbidden in workflows"
+# @expect error E_VALIDATE "invalid send: channel must be a single name or" @2:3
--- input.jh
workflow default() {
a.b.c <- "x"
@@ -865,3 +842,80 @@ workflow ask() {
return r
}
+=== invalid regex in if condition in workflow
+# @expect error E_VALIDATE "invalid regex in if condition"
+--- input.jh
+workflow default() {
+ const x = "test"
+ if x =~ /[bad(/ {
+ log "match"
+ }
+}
+
+=== invalid regex in if condition in rule
+# @expect error E_VALIDATE "invalid regex in if condition"
+--- input.jh
+script noop = `:`
+rule check() {
+ const x = "test"
+ if x =~ /[bad(/ {
+ run noop()
+ }
+}
+workflow default() {
+ ensure check()
+}
+
+=== import script resolves to missing file
+# @expect error E_IMPORT_NOT_FOUND "resolves to missing file"
+--- input.jh
+import script "./missing.py" as queue
+workflow default() {
+ run queue()
+}
+
+=== bare imported workflow as shell line must use run
+# @expect error E_VALIDATE "is a valid script or workflow reference" @3:3
+--- main.jh
+import "lib.jh" as lib
+workflow default() {
+ lib.deploy
+}
+--- lib.jh
+export workflow deploy() {
+ log "ok"
+}
+
+=== bare imported script as shell line must use run
+# @expect error E_VALIDATE "is a valid script or workflow reference" @3:3
+--- main.jh
+import "lib.jh" as lib
+workflow default() {
+ lib.helper
+}
+--- lib.jh
+export script helper = `echo ok`
+workflow dummy() {
+ log "ok"
+}
+
+=== command substitution invokes rule in send shell RHS
+# @expect error E_VALIDATE "command substitution cannot invoke rule"
+--- input.jh
+channel c
+rule check() {
+ return "ok"
+}
+workflow default() {
+ c <- $(check)
+}
+
+=== command substitution contains channel send
+# @expect error E_VALIDATE "command substitution cannot contain channel send"
+--- input.jh
+channel c
+channel d
+workflow default() {
+ c <- $(d <- "x")
+}
+
diff --git a/golden-ast/expected/brace-if.json b/test-fixtures/golden-ast/expected/brace-if.json
similarity index 98%
rename from golden-ast/expected/brace-if.json
rename to test-fixtures/golden-ast/expected/brace-if.json
index 8dc2580d..1da5f6a0 100644
--- a/golden-ast/expected/brace-if.json
+++ b/test-fixtures/golden-ast/expected/brace-if.json
@@ -56,7 +56,7 @@
"params": [],
"steps": [
{
- "recover": {
+ "catch": {
"bindings": {
"failure": "err"
},
diff --git a/golden-ast/expected/imports.json b/test-fixtures/golden-ast/expected/imports.json
similarity index 100%
rename from golden-ast/expected/imports.json
rename to test-fixtures/golden-ast/expected/imports.json
diff --git a/golden-ast/expected/log.json b/test-fixtures/golden-ast/expected/log.json
similarity index 100%
rename from golden-ast/expected/log.json
rename to test-fixtures/golden-ast/expected/log.json
diff --git a/golden-ast/expected/match-multiline.json b/test-fixtures/golden-ast/expected/match-multiline.json
similarity index 100%
rename from golden-ast/expected/match-multiline.json
rename to test-fixtures/golden-ast/expected/match-multiline.json
diff --git a/golden-ast/expected/match.json b/test-fixtures/golden-ast/expected/match.json
similarity index 100%
rename from golden-ast/expected/match.json
rename to test-fixtures/golden-ast/expected/match.json
diff --git a/golden-ast/expected/params.json b/test-fixtures/golden-ast/expected/params.json
similarity index 100%
rename from golden-ast/expected/params.json
rename to test-fixtures/golden-ast/expected/params.json
diff --git a/golden-ast/expected/prompt-capture.json b/test-fixtures/golden-ast/expected/prompt-capture.json
similarity index 100%
rename from golden-ast/expected/prompt-capture.json
rename to test-fixtures/golden-ast/expected/prompt-capture.json
diff --git a/golden-ast/expected/run-ensure.json b/test-fixtures/golden-ast/expected/run-ensure.json
similarity index 100%
rename from golden-ast/expected/run-ensure.json
rename to test-fixtures/golden-ast/expected/run-ensure.json
diff --git a/golden-ast/expected/script-defs.json b/test-fixtures/golden-ast/expected/script-defs.json
similarity index 100%
rename from golden-ast/expected/script-defs.json
rename to test-fixtures/golden-ast/expected/script-defs.json
diff --git a/golden-ast/fixtures/brace-if.jh b/test-fixtures/golden-ast/fixtures/brace-if.jh
similarity index 100%
rename from golden-ast/fixtures/brace-if.jh
rename to test-fixtures/golden-ast/fixtures/brace-if.jh
diff --git a/golden-ast/fixtures/imports.jh b/test-fixtures/golden-ast/fixtures/imports.jh
similarity index 100%
rename from golden-ast/fixtures/imports.jh
rename to test-fixtures/golden-ast/fixtures/imports.jh
diff --git a/golden-ast/fixtures/log.jh b/test-fixtures/golden-ast/fixtures/log.jh
similarity index 100%
rename from golden-ast/fixtures/log.jh
rename to test-fixtures/golden-ast/fixtures/log.jh
diff --git a/golden-ast/fixtures/match-multiline.jh b/test-fixtures/golden-ast/fixtures/match-multiline.jh
similarity index 100%
rename from golden-ast/fixtures/match-multiline.jh
rename to test-fixtures/golden-ast/fixtures/match-multiline.jh
diff --git a/golden-ast/fixtures/match.jh b/test-fixtures/golden-ast/fixtures/match.jh
similarity index 100%
rename from golden-ast/fixtures/match.jh
rename to test-fixtures/golden-ast/fixtures/match.jh
diff --git a/golden-ast/fixtures/params.jh b/test-fixtures/golden-ast/fixtures/params.jh
similarity index 100%
rename from golden-ast/fixtures/params.jh
rename to test-fixtures/golden-ast/fixtures/params.jh
diff --git a/golden-ast/fixtures/prompt-capture.jh b/test-fixtures/golden-ast/fixtures/prompt-capture.jh
similarity index 100%
rename from golden-ast/fixtures/prompt-capture.jh
rename to test-fixtures/golden-ast/fixtures/prompt-capture.jh
diff --git a/golden-ast/fixtures/run-ensure.jh b/test-fixtures/golden-ast/fixtures/run-ensure.jh
similarity index 100%
rename from golden-ast/fixtures/run-ensure.jh
rename to test-fixtures/golden-ast/fixtures/run-ensure.jh
diff --git a/golden-ast/fixtures/script-defs.jh b/test-fixtures/golden-ast/fixtures/script-defs.jh
similarity index 100%
rename from golden-ast/fixtures/script-defs.jh
rename to test-fixtures/golden-ast/fixtures/script-defs.jh
diff --git a/test/expected/bootstrap_project.sh b/test-fixtures/sample-build/expected/bootstrap_project.sh
similarity index 100%
rename from test/expected/bootstrap_project.sh
rename to test-fixtures/sample-build/expected/bootstrap_project.sh
diff --git a/test/expected/main.sh b/test-fixtures/sample-build/expected/main.sh
similarity index 100%
rename from test/expected/main.sh
rename to test-fixtures/sample-build/expected/main.sh
diff --git a/test/expected/tools/security.sh b/test-fixtures/sample-build/expected/tools/security.sh
similarity index 100%
rename from test/expected/tools/security.sh
rename to test-fixtures/sample-build/expected/tools/security.sh
diff --git a/test/fixtures/bootstrap_project.jh b/test-fixtures/sample-build/fixtures/bootstrap_project.jh
similarity index 100%
rename from test/fixtures/bootstrap_project.jh
rename to test-fixtures/sample-build/fixtures/bootstrap_project.jh
diff --git a/test/fixtures/inbox.jh b/test-fixtures/sample-build/fixtures/inbox.jh
similarity index 100%
rename from test/fixtures/inbox.jh
rename to test-fixtures/sample-build/fixtures/inbox.jh
diff --git a/test/fixtures/inbox.test.jh b/test-fixtures/sample-build/fixtures/inbox.test.jh
similarity index 100%
rename from test/fixtures/inbox.test.jh
rename to test-fixtures/sample-build/fixtures/inbox.test.jh
diff --git a/test/fixtures/lang_redesign_smoke.jh b/test-fixtures/sample-build/fixtures/lang_redesign_smoke.jh
similarity index 100%
rename from test/fixtures/lang_redesign_smoke.jh
rename to test-fixtures/sample-build/fixtures/lang_redesign_smoke.jh
diff --git a/test/fixtures/main.jh b/test-fixtures/sample-build/fixtures/main.jh
similarity index 100%
rename from test/fixtures/main.jh
rename to test-fixtures/sample-build/fixtures/main.jh
diff --git a/test/fixtures/tools/security.jh b/test-fixtures/sample-build/fixtures/tools/security.jh
similarity index 100%
rename from test/fixtures/tools/security.jh
rename to test-fixtures/sample-build/fixtures/tools/security.jh
diff --git a/src/compiler-test-runner.ts b/test-infra/compiler-test-runner.ts
similarity index 97%
rename from src/compiler-test-runner.ts
rename to test-infra/compiler-test-runner.ts
index 0378ccf2..7db6c0cd 100644
--- a/src/compiler-test-runner.ts
+++ b/test-infra/compiler-test-runner.ts
@@ -3,9 +3,9 @@ import assert from "node:assert/strict";
import { readFileSync, writeFileSync, mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
-import { parsejaiph } from "./parser";
-import { validateReferences } from "./transpile/validate";
-import { resolveImportPath } from "./transpile/resolve";
+import { parsejaiph } from "../src/parser";
+import { validateReferences } from "../src/transpile/validate";
+import { resolveImportPath } from "../src/transpile/resolve";
// --- txtar parser ---
@@ -190,7 +190,7 @@ export function expectFailure(tc: TxtarTestCase): boolean {
// --- main: discover and run all txtar files ---
-const fixturesDir = resolve(process.cwd(), "compiler-tests");
+const fixturesDir = resolve(process.cwd(), "test-fixtures/compiler-txtar");
const txtarFiles = readdirSync(fixturesDir).filter((f) => f.endsWith(".txt"));
for (const file of txtarFiles) {
diff --git a/src/golden-ast-runner.ts b/test-infra/golden-ast-runner.ts
similarity index 89%
rename from src/golden-ast-runner.ts
rename to test-infra/golden-ast-runner.ts
index a9aa1f59..a44607be 100644
--- a/src/golden-ast-runner.ts
+++ b/test-infra/golden-ast-runner.ts
@@ -2,8 +2,8 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { join, resolve, basename } from "node:path";
-import { parsejaiph } from "./parser";
-import { jaiphModule } from "./types";
+import { parsejaiph } from "../src/parser";
+import { jaiphModule } from "../src/types";
// --- AST serializer for golden tests ---
@@ -33,8 +33,8 @@ function stripLocations(value: unknown): unknown {
// --- golden test runner ---
-const fixturesDir = resolve(process.cwd(), "golden-ast/fixtures");
-const expectedDir = resolve(process.cwd(), "golden-ast/expected");
+const fixturesDir = resolve(process.cwd(), "test-fixtures/golden-ast/fixtures");
+const expectedDir = resolve(process.cwd(), "test-fixtures/golden-ast/expected");
const updateMode = process.env.UPDATE_GOLDEN === "1";
const fixtures = readdirSync(fixturesDir).filter((f) => f.endsWith(".jh")).sort();
diff --git a/test/sample-build.test.ts b/test/sample-build.test.ts
deleted file mode 100644
index 2362ad43..00000000
--- a/test/sample-build.test.ts
+++ /dev/null
@@ -1,2820 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { basename, dirname, join } from "node:path";
-import { spawnSync } from "node:child_process";
-import { buildScripts, resolveImportPath, walkTestFiles } from "../src/transpiler";
-import { parsejaiph } from "../src/parser";
-import { buildRunTreeRows } from "../src/cli";
-import { formatRunningBottomLine } from "../src/cli/run/progress";
-import { parseStepEvent } from "../src/cli/run/events";
-
-// Inherited JAIPH_RUNS_DIR (e.g. from a developer shell) would send runs outside each temp
-// workspace; these tests expect artifacts under `/.jaiph/runs`.
-delete process.env.JAIPH_RUNS_DIR;
-
-/** Resolve latest run directory. Layout: runsRoot/YYYY-MM-DD/HH-MM-SS-source/ */
-function getLatestRunDir(runsRoot: string): string {
- const dateDirs = readdirSync(runsRoot)
- .filter((n) => /^\d{4}-\d{2}-\d{2}$/.test(n))
- .sort();
- assert.ok(dateDirs.length > 0, "expected at least one date directory under " + runsRoot);
- const dateDirPath = join(runsRoot, dateDirs[dateDirs.length - 1]);
- const runDirNames = readdirSync(dateDirPath).sort();
- assert.ok(runDirNames.length > 0, "expected at least one run directory under " + dateDirPath);
- return join(dateDirPath, runDirNames[runDirNames.length - 1]);
-}
-
-function readCombinedRunLogs(runDir: string): { out: string; err: string } {
- const files = readdirSync(runDir);
- const out = files
- .filter((name) => name.endsWith(".out"))
- .map((name) => readFileSync(join(runDir, name), "utf8"))
- .join("\n");
- const err = files
- .filter((name) => name.endsWith(".err"))
- .map((name) => readFileSync(join(runDir, name), "utf8"))
- .join("\n");
- return { out, err };
-}
-
-test("buildScripts extracts scripts for fixture corpus", () => {
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-build-"));
- try {
- buildScripts(join(process.cwd(), "test/fixtures"), outDir);
- const scriptsDir = join(outDir, "scripts");
- assert.ok(existsSync(scriptsDir));
- assert.ok(readdirSync(scriptsDir).length > 0);
- } finally {
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("build validates imported rule references with deterministic errors", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-invalid-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- 'import "./mod.jh" as mod',
- "",
- "script local_impl = `echo ok`",
- "rule local() {",
- " run local_impl()",
- "}",
- "",
- "workflow main() {",
- " ensure mod.missing()",
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "mod.jh"),
- [
- "script existing_impl = `echo hi`",
- "rule existing() {",
- " run existing_impl()",
- "}",
- "",
- "workflow mod() {",
- " ensure existing()",
- "}",
- "",
- ].join("\n"),
- );
-
- assert.throws(() => buildScripts(root, join(root, "out")), /E_VALIDATE imported rule "mod\.missing" does not exist/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("build fails on missing import file", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-import-missing-"));
- try {
- mkdirSync(join(root, "sub"));
- writeFileSync(
- join(root, "sub/entry.jh"),
- [
- 'import "../missing/mod.jh" as mod',
- "",
- "rule local() {",
- " echo ok",
- "}",
- "",
- "workflow entry() {",
- " ensure local()",
- " ensure mod.anything()",
- "}",
- "",
- ].join("\n"),
- );
-
- assert.throws(() => buildScripts(root, join(root, "out")), /E_IMPORT_NOT_FOUND import "mod" resolves to missing file/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-// Regression: .jaiph/main.jh once imported implement_from_queue.jh which had been
-// renamed to engineer.jh, causing E_IMPORT_NOT_FOUND for every `jaiph test` run
-// in the workspace. `jaiph test` now builds from the test file entrypoint only;
-// this still checks main.jh imports and that the whole `.jaiph` graph builds.
-test(".jaiph/main.jh imports only existing modules", () => {
- const jaiphDir = join(process.cwd(), ".jaiph");
- const mainJh = join(jaiphDir, "main.jh");
- assert.ok(existsSync(mainJh), ".jaiph/main.jh should exist");
-
- const ast = parsejaiph(readFileSync(mainJh, "utf8"), mainJh);
- for (const imp of ast.imports) {
- const resolved = resolveImportPath(mainJh, imp.path, process.cwd());
- assert.ok(existsSync(resolved), `import "${imp.alias}" resolves to missing file "${resolved}"`);
- }
-
- const outDir = join(jaiphDir, ".tmp-build-out");
- try {
- assert.doesNotThrow(() => buildScripts(jaiphDir, outDir, process.cwd()));
- } finally {
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("jaiph run compiles and executes workflow with args", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-"));
- try {
- const filePath = join(root, "echo.jh");
- writeFileSync(
- filePath,
- [
- "script print_arg = \`\`\`",
- "printf '%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default(name) {",
- " run print_arg(name)",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-run"], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /workflow default/);
- assert.match(runResult.stdout, /✓ PASS workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run resolves nested managed call arguments", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-nested-args-"));
- try {
- const filePath = join(root, "nested_args.jh");
- writeFileSync(
- filePath,
- [
- "script mkdir_p_simple = ```",
- 'mkdir -p "$1"',
- "```",
- "script jaiph_tmp_dir = ```",
- 'printf "%s\\n" "$JAIPH_WORKSPACE/.jaiph/tmp"',
- "```",
- "workflow default() {",
- " run mkdir_p_simple(run jaiph_tmp_dir())",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.equal(existsSync(join(root, ".jaiph", "tmp")), true);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("executable .jh invokes jaiph run semantics", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-exec-jh-"));
- try {
- const filePath = join(root, "echo.jh");
- writeFileSync(
- filePath,
- [
- "#!/usr/bin/env jaiph",
- "",
- "script print_exec_arg = \`\`\`",
- "printf 'exec-arg:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default(name) {",
- " run print_exec_arg(name)",
- "}",
- "",
- ].join("\n"),
- );
- chmodSync(filePath, 0o755);
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, filePath, "hello-exec"], {
- encoding: "utf8",
- cwd: root,
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /✓ PASS workflow default/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run enables xtrace when JAIPH_DEBUG=true", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-debug-"));
- try {
- const filePath = join(root, "debug.jh");
- writeFileSync(
- filePath,
- [
- "script print_debug_arg = \`\`\`",
- "printf 'debug-run:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default(name) {",
- " run print_debug_arg(name)",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-debug"], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DEBUG: "true", JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.ok(runResult.stderr.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run fails when workflow default is missing", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-missing-default-"));
- try {
- const filePath = join(root, "pr.jh");
- writeFileSync(
- filePath,
- [
- "script print_fallback = \`\`\`",
- "printf 'fallback:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow main(name) {",
- " run print_fallback(name)",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath, "hello-main"], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 1);
- assert.match(runResult.stderr, /requires workflow 'default'/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run fails fast on command errors inside workflow", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-fail-fast-"));
- try {
- const filePath = join(root, "fail-fast.jh");
- writeFileSync(
- filePath,
- [
- "script always_fail = `false`",
- "script should_not_run = `echo after-false`",
- "workflow default() {",
- " run always_fail()",
- " run should_not_run()",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 1);
- assert.doesNotMatch(runResult.stdout, /after-false/);
- assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
- assert.match(runResult.stderr, /Logs: /);
- assert.match(runResult.stderr, /Summary: /);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run fails when runtime emits non-xtrace stderr", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-runtime-stderr-"));
- try {
- const filePath = join(root, "runtime-stderr.jh");
- writeFileSync(
- filePath,
- [
- "workflow default() {",
- ' log "noop"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_DOCKER_ENABLED: "false",
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run fails when required arg is missing and rule handles it", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-missing-arg-"));
- try {
- const filePath = join(root, "missing-arg.jh");
- writeFileSync(
- filePath,
- [
- "script require_name = \`\`\`",
- "if [ -z \"$1\" ]; then",
- " echo \"missing-name\" >&2",
- " exit 1",
- "fi",
- "\`\`\`",
- "rule name_provided(name) {",
- " run require_name(name)",
- "}",
- "",
- "workflow default(name) {",
- " ensure name_provided(name)",
- ' prompt "Say hello to ${name}"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 1);
- assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
- assert.match(runResult.stderr, /Logs: /);
- assert.match(runResult.stderr, /Summary: /);
- assert.doesNotMatch(runResult.stderr, /unbound variable/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run allows rules to call top-level helper functions in readonly mode", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-rule-helper-fn-"));
- try {
- const filePath = join(root, "helpers.jh");
- writeFileSync(
- filePath,
- [
- "script helper_value = `echo ok`",
- "script helper_is_ok_impl = \`\`\`",
- 'test "ok" = "ok"',
- "\`\`\`",
- "",
- "rule helper_is_ok() {",
- " run helper_is_ok_impl()",
- "}",
- "",
- "workflow default() {",
- " ensure helper_is_ok()",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /✓ PASS workflow default/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run prints rule tree and fail summary", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-fail-"));
- try {
- const filePath = join(root, "fail.jh");
- writeFileSync(
- filePath,
- [
- "script current_branch_impl = \`\`\`",
- "echo \"Current branch is not 'main'.\" >&2",
- "exit 1",
- "\`\`\`",
- "rule current_branch() {",
- " run current_branch_impl()",
- "}",
- "",
- "workflow default() {",
- " ensure current_branch()",
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 1);
- assert.match(runResult.stdout, /workflow default/);
- assert.match(runResult.stdout, /▸ rule current_branch/);
- assert.match(runResult.stdout, /✗ rule current_branch \(\d+s\)/);
- assert.match(runResult.stderr, /✗ FAIL workflow default \((?:\d+(?:\.\d+)?s|\d+m \d+s)\)/);
- assert.match(runResult.stderr, /Logs: /);
- assert.match(runResult.stderr, /Summary: /);
- assert.match(runResult.stderr, /err: /);
- assert.match(runResult.stderr, /\.jaiph\/runs\//);
- const errPathMatch = runResult.stderr.match(/err: (.+)/);
- assert.equal(Boolean(errPathMatch), true);
- const errLog = readFileSync(errPathMatch![1], "utf8");
- assert.match(errLog, /Current branch is not 'main'\./);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run stores prompt output in run logs", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-output:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "workflow default() {",
- ' prompt "hello from prompt"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- assert.equal(existsSync(runsRoot), true);
- const latestRunDir = getLatestRunDir(runsRoot);
- const runDirName = dirname(latestRunDir).startsWith(runsRoot) ? dirname(latestRunDir).slice(runsRoot.length + 1) : "";
- const dateDirName = runDirName ? runDirName.split("/")[0] : "";
- assert.match(dateDirName, /^\d{4}-\d{2}-\d{2}$/);
- assert.match(basename(latestRunDir), /^\d{2}-\d{2}-\d{2}-/);
- const runFiles = readdirSync(latestRunDir);
- assert.equal(runFiles.includes("run_summary.jsonl"), true);
- const { out: promptOut, err: promptErr } = readCombinedRunLogs(latestRunDir);
- // Node runtime may route prompt transcript differently; keep artifact contract checks.
- assert.ok(promptOut.length >= 0);
- assert.ok(promptErr.length >= 0);
- const summary = readFileSync(join(latestRunDir, "run_summary.jsonl"), "utf8");
- assert.match(summary, /"type":"STEP_END"/);
- assert.match(summary, /"kind":"workflow"/);
- const stepLogFiles = runFiles.filter((name) => name.endsWith(".out"));
- assert.ok(stepLogFiles.length >= 1);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run stores both reasoning and final answer from stream-json", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-stream-json-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo '{\"type\":\"thinking\",\"text\":\"Plan: check name.\"}'",
- "echo '{\"type\":\"thinking\",\"text\":\" Then answer.\"}'",
- "echo '{\"type\":\"result\",\"result\":\"Hello Mike! Fun fact: Mike Shinoda co-founded Linkin Park.\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt-stream-json.jh");
- writeFileSync(
- filePath,
- [
- "workflow default() {",
- ' prompt "hello from prompt"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_AGENT_TRUSTED_WORKSPACE: undefined,
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("build rejects command substitution in prompt text", () => {
- const rootSubshell = mkdtempSync(join(tmpdir(), "jaiph-build-prompt-subshell-"));
- try {
- writeFileSync(
- join(rootSubshell, "main.jh"),
- [
- "workflow default() {",
- ' prompt "literal command substitution: $(echo SHOULD_NOT_RUN)"',
- "}",
- "",
- ].join("\n"),
- );
- assert.throws(
- () => buildScripts(rootSubshell, join(rootSubshell, "out")),
- /E_PARSE prompt cannot contain command substitution/,
- );
- } finally {
- rmSync(rootSubshell, { recursive: true, force: true });
- }
-});
-
-test("jaiph run interpolates positional args in prompt text", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-args-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-arg:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt-args.jh");
- writeFileSync(
- filePath,
- [
- "workflow default(name) {",
- ' prompt "Say hello to ${name} and mention ${name} again."',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath, "Alice"], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run interpolates named array placeholders in prompt text", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-array-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"prompt-array:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt-array.jh");
- writeFileSync(
- filePath,
- [
- "const DOCS = \"README.md docs/cli.md\"",
- "workflow default() {",
- ' prompt """',
- "Files to keep in sync:",
- "${DOCS}",
- '"""',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run applies model from in-file metadata", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-metadata-model-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"model-args:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "config {",
- ' agent.default_model = "auto"',
- ' agent.cursor_flags = "--force --sandbox enabled"',
- "}",
- "workflow default() {",
- ' prompt "hello from metadata"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runEnv: NodeJS.ProcessEnv = {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- };
- delete runEnv.JAIPH_AGENT_CURSOR_FLAGS;
- delete runEnv.JAIPH_AGENT_MODEL;
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: runEnv,
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run supports agent.command with inline args", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-agent-command-args-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cmd-args:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "config {",
- ' agent.command = "cursor-agent --force"',
- "}",
- "workflow default() {",
- ' prompt "hello from command args"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run agent.backend = claude uses Claude CLI and captures output", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-claude-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeClaude = join(binDir, "claude");
- writeFileSync(
- fakeClaude,
- [
- "#!/usr/bin/env bash",
- "cat",
- "echo '{\"type\":\"result\",\"result\":\"claude-backend-output '$*'\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeClaude, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "script print_captured = \`\`\`",
- "printf 'captured:%s\\n' \"$1\"",
- "\`\`\`",
- "config {",
- ' agent.backend = "claude"',
- ' agent.claude_flags = "--model sonnet-4"',
- "}",
- "workflow default() {",
- ' const result = prompt "hello"',
- ' run print_captured(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runEnv: NodeJS.ProcessEnv = {
- ...process.env,
- JAIPH_DOCKER_ENABLED: "false",
- NODE_NO_WARNINGS: "1",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- };
- delete runEnv.JAIPH_AGENT_BACKEND;
- delete runEnv.JAIPH_AGENT_CLAUDE_FLAGS;
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: runEnv,
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
- assert.match(workflowOut, /captured:[\s\S]*claude-backend-output/);
- assert.match(workflowOut, /captured:[\s\S]*--model sonnet-4/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run agent.backend = claude without claude in PATH fails with clear error", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-claude-missing-"));
- try {
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "config {",
- ' agent.backend = "claude"',
- "}",
- "workflow default() {",
- ' prompt "hello"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runEnv: NodeJS.ProcessEnv = {
- ...process.env,
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${dirname(process.execPath)}:/bin:/usr/bin:/nonexistent`,
- };
- delete runEnv.JAIPH_AGENT_BACKEND;
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: runEnv,
- });
-
- assert.equal(runResult.status, 1);
- assert.match(
- runResult.stderr + runResult.stdout,
- /agent\.backend is "claude" but the Claude CLI.*not found|JAIPH_AGENT_BACKEND=cursor/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run JAIPH_AGENT_BACKEND env overrides file default", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-backend-env-override-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeCursor = join(binDir, "cursor-agent");
- writeFileSync(
- fakeCursor,
- [
- "#!/usr/bin/env bash",
- "echo '{\"type\":\"result\",\"result\":\"cursor-from-env\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeCursor, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "script print_out = \`\`\`",
- "printf 'out:%s\\n' \"$1\"",
- "\`\`\`",
- "config {",
- ' agent.backend = "claude"',
- "}",
- "workflow default() {",
- ' const result = prompt "hi"',
- ' run print_out(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
- assert.match(workflowOut, /out:[\s\S]*cursor-from-env/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run defaults Cursor trusted workspace to project root", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-trust-default-"));
- try {
- mkdirSync(join(root, ".jaiph"), { recursive: true });
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeCursor = join(binDir, "cursor-agent");
- writeFileSync(
- fakeCursor,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cursor-args:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeCursor, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "script print_out = \`\`\`",
- "printf 'out:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default() {",
- ' const result = prompt "hi"',
- ' run print_out(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const { JAIPH_AGENT_TRUSTED_WORKSPACE: _drop, ...env } = process.env as Record;
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.match(promptOut, new RegExp(`--trust ${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run JAIPH_AGENT_TRUSTED_WORKSPACE env overrides metadata", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-trust-env-override-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeCursor = join(binDir, "cursor-agent");
- writeFileSync(
- fakeCursor,
- [
- "#!/usr/bin/env bash",
- "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"cursor-args:$*\\\"}\"",
- "",
- ].join("\n"),
- );
- chmodSync(fakeCursor, 0o755);
-
- const filePath = join(root, "prompt.jh");
- writeFileSync(
- filePath,
- [
- "script print_out = \`\`\`",
- "printf 'out:%s\\n' \"$1\"",
- "\`\`\`",
- "config {",
- ' agent.trusted_workspace = ".jaiph/.."',
- "}",
- "workflow default() {",
- ' const result = prompt "hi"',
- ' run print_out(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_AGENT_TRUSTED_WORKSPACE: "/tmp/jaiph-explicit-trust",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.match(promptOut, /--trust \/tmp\/jaiph-explicit-trust/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-
-test("jaiph init creates workspace structure and guidance", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-init-"));
- try {
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const skillPath = join(process.cwd(), "docs/jaiph-skill.md");
- const initResult = spawnSync("node", [cliPath, "init"], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, ...(existsSync(skillPath) ? { JAIPH_SKILL_PATH: skillPath } : {}) },
- });
-
- assert.equal(initResult.status, 0, initResult.stderr);
- assert.equal(existsSync(join(root, ".jaiph")), true);
- assert.equal(existsSync(join(root, ".jaiph/lib")), false);
- assert.equal(existsSync(join(root, ".jaiph/bootstrap.jh")), true);
- assert.equal(existsSync(join(root, ".jaiph/SKILL.md")), true);
- const bootstrap = readFileSync(join(root, ".jaiph/bootstrap.jh"), "utf8");
- assert.match(bootstrap, /^#!\/usr\/bin\/env jaiph\n\n/);
- assert.match(bootstrap, /workflow default\(\) \{/);
- assert.match(bootstrap, /\.jaiph\/SKILL\.md/);
- assert.match(bootstrap, /Analyze repository structure/);
- assert.match(bootstrap, /Create or update Jaiph workflows under \.jaiph\//);
- assert.doesNotMatch(bootstrap, /\$1/);
- assert.equal(statSync(join(root, ".jaiph/bootstrap.jh")).mode & 0o777, 0o755);
- const localSkill = readFileSync(join(root, ".jaiph/SKILL.md"), "utf8");
- assert.match(localSkill, /Jaiph Bootstrap Skill/);
- assert.equal(existsSync(join(root, ".gitignore")), false);
- assert.equal(readFileSync(join(root, ".jaiph", ".gitignore"), "utf8"), "runs\ntmp\n");
- assert.match(initResult.stdout, /Jaiph init/);
- assert.match(initResult.stdout, /▸ Creating \.jaiph\/bootstrap\.jh/);
- assert.match(initResult.stdout, /✓ Initialized \.jaiph\/bootstrap\.jh/);
- assert.match(initResult.stdout, /✓ Created \.jaiph\/\.gitignore/);
- assert.match(initResult.stdout, /Wrote \.jaiph\/SKILL\.md from installation/);
- assert.match(initResult.stdout, /\.\/\.jaiph\/bootstrap\.jh/);
- assert.match(initResult.stdout, /analyze the project/i);
- assert.match(initResult.stdout, /\.jaiph\/\.gitignore/i);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph use maps nightly and version refs for reinstallation", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-use-"));
- try {
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const installSpy = join(root, "install-spy.sh");
- const outputPath = join(root, "used-ref.txt");
- writeFileSync(
- installSpy,
- [
- "#!/usr/bin/env bash",
- "set -euo pipefail",
- "printf '%s' \"$JAIPH_REPO_REF\" > \"$JAIPH_USE_REF_OUT\"",
- "",
- ].join("\n"),
- );
- chmodSync(installSpy, 0o755);
-
- const nightlyResult = spawnSync("node", [cliPath, "use", "nightly"], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_INSTALL_COMMAND: `"${installSpy}"`,
- JAIPH_USE_REF_OUT: outputPath,
- },
- });
- assert.equal(nightlyResult.status, 0, nightlyResult.stderr);
- assert.equal(readFileSync(outputPath, "utf8"), "nightly");
-
- const versionResult = spawnSync("node", [cliPath, "use", "0.2.3"], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_INSTALL_COMMAND: `"${installSpy}"`,
- JAIPH_USE_REF_OUT: outputPath,
- },
- });
- assert.equal(versionResult.status, 0, versionResult.stderr);
- assert.equal(readFileSync(outputPath, "utf8"), "v0.2.3");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("buildScripts accepts files with no workflows", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-no-workflows-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-no-workflows-out-"));
- try {
- const filePath = join(root, "rules-only.jh");
- writeFileSync(
- filePath,
- [
- "script only_rule_impl = `echo ok`",
- "rule only_rule() {",
- " run only_rule_impl()",
- "}",
- "",
- ].join("\n"),
- );
-
- buildScripts(filePath, outDir);
- assert.ok(existsSync(join(outDir, "scripts", "only_rule_impl")));
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("buildScripts extracts scripts for ensure-with-args workflow", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-args-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-args-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script check_branch_impl = \`\`\`",
- "test \"$1\" = \"main\"",
- "\`\`\`",
- "rule check_branch(branch) {",
- " run check_branch_impl(branch)",
- "}",
- "",
- "workflow default(name) {",
- " ensure check_branch(name)",
- "}",
- "",
- ].join("\n"),
- );
-
- buildScripts(filePath, outDir);
- assert.ok(readFileSync(join(outDir, "scripts", "check_branch_impl"), "utf8").includes("test "));
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("buildScripts writes multiple script stubs", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-functions-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-functions-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script changed_files = `printf '%s' 'from-function'`",
- "script print_value = \`\`\`",
- "printf '%s\\n' \"$1\"",
- "\`\`\`",
- "",
- "workflow default() {",
- " const VALUE = run changed_files()",
- ' run print_value(VALUE)',
- "}",
- "",
- ].join("\n"),
- );
-
- buildScripts(filePath, outDir);
- const names = readdirSync(join(outDir, "scripts")).sort();
- assert.deepEqual(names, ["changed_files", "print_value"]);
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("jaiph run tree includes function calls from workflow shell steps", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-function-tree-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script changed_files = `printf '%s' 'from-function'`",
- "script print_value = \`\`\`",
- "printf '%s\\n' \"$1\"",
- "\`\`\`",
- "",
- "workflow default() {",
- " const VALUE = run changed_files()",
- ' run print_value(VALUE)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /workflow default/);
- assert.match(runResult.stdout, /script changed_files/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("parseStepEvent parses params array from event payload", () => {
- const line =
- '__JAIPH_EVENT__ {"type":"STEP_START","func":"main::docs_page","kind":"workflow","name":"docs_page","ts":"2025-01-01T00:00:00Z","status":null,"elapsed_ms":null,"out_file":"","err_file":"","id":"run:1:1","parent_id":"run:0:0","seq":1,"depth":1,"run_id":"run-1","params":[["path","docs/cli.md"],["mode","strict"]]}';
- const event = parseStepEvent(line);
- assert.ok(event);
- assert.equal(event?.kind, "workflow");
- assert.equal(event?.name, "docs_page");
- assert.equal(event?.params?.length, 2);
- assert.deepEqual(event?.params?.[0], ["path", "docs/cli.md"]);
- assert.deepEqual(event?.params?.[1], ["mode", "strict"]);
-});
-
-test("parseStepEvent returns empty params when payload has no params", () => {
- const line =
- '__JAIPH_EVENT__ {"type":"STEP_START","func":"main::default","kind":"workflow","name":"default","ts":"2025-01-01T00:00:00Z","status":null,"elapsed_ms":null,"out_file":"","err_file":"","id":"run:1:1","parent_id":null,"seq":1,"depth":0,"run_id":"run-1"}';
- const event = parseStepEvent(line);
- assert.ok(event);
- assert.equal(event?.params?.length, 0);
-});
-
-test("formatRunningBottomLine produces TTY bottom line with RUNNING, workflow name, and elapsed time", () => {
- const line = formatRunningBottomLine("default", 2.6);
- assert.ok(line.includes("RUNNING"), "contains RUNNING");
- assert.ok(line.includes("workflow"), "contains workflow");
- assert.ok(line.includes("default"), "contains workflow name");
- assert.match(line, /\(\d+\.\ds\)/, "contains (X.Xs) time");
-});
-
-test("jaiph run tree shows workflow params inline when run has key=value args", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-params-"));
- try {
- writeFileSync(
- join(root, "sub.jh"),
- ["script done_impl = `echo done`", "workflow default(path, mode) {", " run done_impl()", "}", ""].join("\n"),
- );
- writeFileSync(
- join(root, "main.jh"),
- [
- 'import "sub.jh" as sub',
- "workflow default() {",
- ' run sub.default(path="docs/cli.md" mode="strict")',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
- });
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /workflow default/);
- // Nested workflow step is shown (rootStepId fix); params inline when runtime sends them
- assert.match(runResult.stdout, /▸ workflow default/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run tree shows function step; params shown when runtime includes them", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-fn-params-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "script echo_args = \`\`\`",
- "printf '%s %s\\n' \"$1\" \"$2\"",
- "\`\`\`",
- "workflow default() {",
- ' run echo_args("first" "second")',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
- });
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /script echo_args/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run tree truncates param values over 32 chars when params present", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-tree-truncate-"));
- try {
- const longValue = "a".repeat(40);
- writeFileSync(
- join(root, "sub.jh"),
- ["script done_impl = `echo done`", "workflow default(longparam) {", " run done_impl()", "}", ""].join("\n"),
- );
- writeFileSync(
- join(root, "main.jh"),
- [
- 'import "sub.jh" as sub',
- "workflow default() {",
- ` run sub.default(longparam="${longValue}")`,
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false", NO_COLOR: "1" },
- });
- assert.equal(runResult.status, 0, runResult.stderr);
- assert.match(runResult.stdout, /workflow default/);
- // When params are shown, long values are truncated to 32 chars + "..."
- if (/longparam=/.test(runResult.stdout)) {
- assert.match(runResult.stdout, /longparam="a{32}\.\.\./);
- }
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test runs workflow with mocked prompts", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-"));
- try {
- writeFileSync(
- join(root, "hello.jh"),
- [
- "workflow default() {",
- ' prompt "Please greet the user"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "hello.test.jh"),
- [
- 'import "hello.jh" as h',
- "",
- 'test "workflow default" {',
- ' mock prompt "Mocked greeting output"',
- " const response = run h.default()",
- ' expect_contain response "Mocked greeting output"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "hello.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
-
- assert.equal(testResult.status, 0, testResult.stderr);
- assert.match(testResult.stdout, /test\(s\) passed|PASS/);
- assert.match(testResult.stdout, /test happy path|workflow default/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("buildRunTreeRows expands nested workflow from imported module", () => {
- const mainSource = [
- 'import "sub.jh" as sub',
- "workflow default() {",
- " run sub.default()",
- "}",
- "",
- ].join("\n");
- const subSource = [
- "workflow default() {",
- ' prompt "nested prompt"',
- "}",
- "",
- ].join("\n");
- const mainMod = parsejaiph(mainSource, "/fake/main.jh");
- const subMod = parsejaiph(subSource, "/fake/sub.jh");
- const importedModules = new Map>([
- ["sub", subMod],
- ]);
- const rows = buildRunTreeRows(mainMod, "workflow default", importedModules, "/fake");
- assert.equal(rows.length, 3);
- assert.equal(rows[0].rawLabel, "workflow default");
- assert.equal(rows[0].isRoot, true);
- assert.equal(rows[1].rawLabel, "workflow sub.default");
- assert.equal(rows[2].rawLabel, 'prompt "nested prompt"');
-});
-
-test("jaiph run shows nested workflow subtree and step timing", () => {
- const rootRaw = mkdtempSync(join(tmpdir(), "jaiph-run-subtree-"));
- const root = realpathSync(rootRaw);
- try {
- writeFileSync(
- join(root, "sub.jh"),
- [
- "workflow default() {",
- ' prompt "nested prompt"',
- "}",
- "",
- ].join("\n"),
- );
- const mainPath = join(root, "main.jh");
- writeFileSync(
- mainPath,
- [
- 'import "sub.jh" as sub',
- "workflow default() {",
- " run sub.default()",
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "main.test.jh"),
- [
- 'import "main.jh" as m',
- "",
- 'test "nested workflow" {',
- ' mock prompt "mocked"',
- " const response = run m.default()",
- ' expect_contain response "mocked"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", join(root, "main.test.jh")], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
-
- assert.equal(testResult.status, 0, testResult.stderr);
- assert.match(testResult.stdout, /test\(s\) passed|PASS/);
- } finally {
- rmSync(rootRaw, { recursive: true, force: true });
- }
-});
-
-test("jaiph test fails when no mock matches prompt", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-no-mock-"));
- try {
- writeFileSync(
- join(root, "hello.jh"),
- [
- "workflow default() {",
- ' prompt "Please greet the user"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "hello.test.jh"),
- [
- 'import "hello.jh" as h',
- "",
- 'test "no mock for prompt" {',
- " const response = run h.default()",
- ' expect_contain response "no mock"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "hello.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- PATH: `${dirname(process.execPath)}:/bin:/usr/bin`,
- },
- });
-
- assert.equal(testResult.status, 1, "expected test run to fail when prompt has no mock");
- assert.match(testResult.stderr + testResult.stdout, /expect_contain failed|FAIL|no mock|not found|command not found/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test fails when non-test file is passed", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-missing-mock-"));
- try {
- writeFileSync(
- join(root, "hello.jh"),
- [
- "workflow default() {",
- ' prompt "hello"',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "hello.jh"], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
- assert.equal(testResult.status, 1);
- assert.match(testResult.stderr, /\.test\.jh|inline mock/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("build fails when run in rule references unknown symbol", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-in-rule-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-run-in-rule-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "rule bad() {",
- " run some_workflow()",
- "}",
- "",
- "workflow default() {",
- " ensure bad()",
- "}",
- "",
- ].join("\n"),
- );
-
- assert.throws(
- () => buildScripts(filePath, outDir),
- /unknown local script reference.*run in rules must target a script/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("build fails when run in rule targets a workflow", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-wf-in-rule-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-run-wf-in-rule-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "workflow helper() {",
- ' log "hi"',
- "}",
- "",
- "rule bad() {",
- " run helper()",
- "}",
- "",
- "workflow default() {",
- " ensure bad()",
- "}",
- "",
- ].join("\n"),
- );
-
- assert.throws(
- () => buildScripts(filePath, outDir),
- /run inside a rule must target a script, not workflow/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("buildScripts accepts ensure inside a rule block", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-in-rule-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-in-rule-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script dep_impl = `echo dep`",
- "rule dep() {",
- " run dep_impl()",
- "}",
- "",
- "rule main() {",
- " ensure dep()",
- "}",
- "",
- "workflow default() {",
- " ensure main()",
- "}",
- "",
- ].join("\n"),
- );
-
- buildScripts(filePath, outDir);
- assert.ok(existsSync(join(outDir, "scripts", "dep_impl")));
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("buildScripts extracts scripts for ensure ... catch workflow", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script dep_impl = `test -f ready.txt`",
- "rule dep() {",
- " run dep_impl()",
- "}",
- "",
- "script install_deps_impl = `touch ready.txt`",
- "",
- "workflow install_deps() {",
- " run install_deps_impl()",
- "}",
- "",
- "workflow default() {",
- " ensure dep() catch (failure) run install_deps()",
- "}",
- "",
- ].join("\n"),
- );
-
- buildScripts(filePath, outDir);
- assert.ok(readdirSync(join(outDir, "scripts")).includes("install_deps_impl"));
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("build rejects ensure catch inline shell block under strict shell-step ban", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-block-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-ensure-catch-block-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "script ready_impl = `test -f ready.txt`",
- "rule ready() {",
- " run ready_impl()",
- "}",
- "",
- "workflow default() {",
- " ensure ready() catch (failure) { echo fixing; touch ready.txt; }",
- "}",
- "",
- ].join("\n"),
- );
-
- assert.throws(
- () => buildScripts(filePath, outDir),
- /inline shell steps are forbidden in workflows; use explicit script blocks/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("buildScripts rejects shell assignment capture under strict shell-step ban", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-assign-fail-"));
- const outDir = mkdtempSync(join(tmpdir(), "jaiph-assign-fail-out-"));
- try {
- const filePath = join(root, "entry.jh");
- writeFileSync(
- filePath,
- [
- "workflow default() {",
- " out = false",
- " echo done",
- "}",
- "",
- ].join("\n"),
- );
- assert.throws(
- () => buildScripts(filePath, outDir),
- /inline shell steps are forbidden in workflows; use explicit script blocks/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- rmSync(outDir, { recursive: true, force: true });
- }
-});
-
-test("jaiph test captures mock response into variable and variable is available in subsequent step", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-prompt-capture-"));
- try {
- writeFileSync(
- join(root, "capture.jh"),
- [
- "workflow default() {",
- ' const result = prompt "Please greet the user"',
- ' return "${result}"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "capture.test.jh"),
- [
- 'import "capture.jh" as c',
- "",
- 'test "capture mock" {',
- ' mock prompt "CAPTURED_MOCK_OUTPUT"',
- " const response = run c.default()",
- ' expect_contain response "CAPTURED_MOCK_OUTPUT"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "capture.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
-
- assert.equal(testResult.status, 0, testResult.stderr);
- assert.match(testResult.stdout, /test\(s\) passed|PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test inline mock prompt block with if/elif/else and first-match", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-block-"));
- try {
- writeFileSync(
- join(root, "multi_prompt.jh"),
- [
- "workflow default() {",
- ' const a = prompt "greet"',
- ' const b = prompt "bye"',
- ' return "${a} ${b}"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "multi_prompt.test.jh"),
- [
- 'import "multi_prompt.jh" as m',
- "",
- 'test "mock block first-match" {',
- " mock prompt {",
- ' /greet/ => "hello"',
- ' /bye/ => "goodbye"',
- ' _ => "default"',
- " }",
- " const out = run m.default()",
- ' expect_contain out "hello"',
- ' expect_contain out "goodbye"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "multi_prompt.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
-
- assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
- assert.match(testResult.stdout, /test\(s\) passed|PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test fails when no mock branch matches and no wildcard", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-no-else-"));
- try {
- writeFileSync(
- join(root, "single.jh"),
- [
- "workflow default() {",
- ' const result = prompt "unmatched prompt text"',
- ' return "${result}"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "single.test.jh"),
- [
- 'import "single.jh" as s',
- "",
- 'test "no wildcard arm" {',
- " mock prompt {",
- ' /other/ => "never"',
- " }",
- " const out = run s.default()",
- ' expect_contain out "x"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "single.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- PATH: `${dirname(process.execPath)}:/bin:/usr/bin`,
- },
- });
-
- assert.equal(testResult.status, 1, "expected test to fail when no branch matches, no wildcard, and no backend in PATH");
- assert.match(
- testResult.stderr + testResult.stdout,
- /workflow exited with status|no mock matched|no branch matched|expect_contain failed|FAIL|not found|command not found/,
- );
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run prompt capture: variable accessible in subsequent shell step", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-capture-"));
- try {
- // Anchor workspace here: a parent of TMPDIR may contain `.jaiph`, which would otherwise
- // become JAIPH_WORKSPACE and send runs outside this temp root.
- mkdirSync(join(root, ".jaiph"), { recursive: true });
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo '{\"type\":\"result\",\"result\":\"agent-summary\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "capture.jh");
- writeFileSync(
- filePath,
- [
- "script print_captured = \`\`\`",
- "printf 'captured:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default() {",
- ' const result = prompt "Summarize"',
- ' run print_captured(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
- assert.match(workflowOut, /captured:[\s\S]*agent-summary/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph run prompt capture stores only final answer in assigned variable", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-run-prompt-capture-final-only-"));
- try {
- mkdirSync(join(root, ".jaiph"), { recursive: true });
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeAgent = join(binDir, "cursor-agent");
- writeFileSync(
- fakeAgent,
- [
- "#!/usr/bin/env bash",
- "echo '{\"type\":\"thinking\",\"text\":\"Plan: inspect data.\"}'",
- "echo '{\"type\":\"result\",\"result\":\"final-only-value\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeAgent, 0o755);
-
- const filePath = join(root, "capture_final_only.jh");
- writeFileSync(
- filePath,
- [
- "script print_captured = \`\`\`",
- "printf 'captured:%s\\n' \"$1\"",
- "\`\`\`",
- "workflow default() {",
- ' const result = prompt "Summarize"',
- ' run print_captured(result)',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const runResult = spawnSync("node", [cliPath, "run", filePath], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- JAIPH_AGENT_BACKEND: "cursor",
- JAIPH_DOCKER_ENABLED: "false",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(runResult.status, 0, runResult.stderr);
- const runsRoot = join(root, ".jaiph/runs");
- const latestRunDir = getLatestRunDir(runsRoot);
- const { out: workflowOut } = readCombinedRunLogs(latestRunDir);
- assert.match(workflowOut, /captured:[\s\S]*final-only-value/);
- assert.doesNotMatch(workflowOut, /captured:[^\n]*Plan: inspect data\./);
-
- const { out: promptOut } = readCombinedRunLogs(latestRunDir);
- assert.ok(promptOut.length >= 0);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test with agent.backend = claude uses mock and does not invoke claude", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-backend-claude-mock-"));
- try {
- writeFileSync(
- join(root, "flow.jh"),
- [
- "script print_got = \`\`\`",
- "printf 'got:%s\\n' \"$1\"",
- "\`\`\`",
- "config {",
- ' agent.backend = "claude"',
- "}",
- "workflow default() {",
- ' const result = prompt "ask"',
- ' run print_got(result)',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "flow.test.jh"),
- [
- 'import "flow.jh" as w',
- "",
- 'test "mock overrides backend" {',
- ' mock prompt "mock-response"',
- " const out = run w.default()",
- ' expect_contain out "mock-response"',
- ' expect_contain out "got:"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, PATH: `${dirname(process.execPath)}:/bin:/usr/bin:/nonexistent` },
- });
-
- assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
- assert.match(testResult.stdout + testResult.stderr, /mock-response|PASS|passed/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test when prompt is not mocked runs selected backend", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-unmocked-backend-"));
- try {
- const binDir = join(root, "bin");
- mkdirSync(binDir, { recursive: true });
- const fakeCursor = join(binDir, "cursor-agent");
- writeFileSync(
- fakeCursor,
- [
- "#!/usr/bin/env bash",
- "echo '{\"type\":\"result\",\"result\":\"backend-ran\"}'",
- "",
- ].join("\n"),
- );
- chmodSync(fakeCursor, 0o755);
-
- writeFileSync(
- join(root, "flow.jh"),
- [
- "script print_got = \`\`\`",
- "printf 'got:%s\\n' \"$1\"",
- "\`\`\`",
- "config {",
- ' agent.backend = "cursor"',
- "}",
- "workflow default() {",
- ' const result = prompt "ask"',
- ' run print_got(result)',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "flow.test.jh"),
- [
- 'import "flow.jh" as w',
- "",
- 'test "no mock uses backend" {',
- " const out = run w.default()",
- ' expect_contain out "backend-ran"',
- ' expect_contain out "got:"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
- encoding: "utf8",
- cwd: root,
- env: {
- ...process.env,
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
- });
-
- assert.equal(testResult.status, 0, testResult.stderr + testResult.stdout);
- assert.match(testResult.stdout + testResult.stderr, /backend-ran|PASS|passed/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test passes for workflow using ensure only with mocks", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-ensure-only-"));
- try {
- writeFileSync(
- join(root, "ensure_only.jh"),
- [
- "script ready_impl = `echo ok`",
- "rule ready() {",
- " run ready_impl()",
- "}",
- "",
- "workflow default() {",
- " ensure ready()",
- ' return "ready-ok"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "ensure_only.test.jh"),
- [
- 'import "ensure_only.jh" as e',
- "",
- 'test "workflow default" {',
- " const response = run e.default()",
- ' expect_contain response "ready-ok"',
- "}",
- "",
- ].join("\n"),
- );
-
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const testResult = spawnSync("node", [cliPath, "test", "ensure_only.test.jh"], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
-
- assert.equal(testResult.status, 0, testResult.stderr);
- assert.match(testResult.stdout, /test\(s\) passed|PASS/);
- assert.match(testResult.stdout, /workflow default/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("parser parses test blocks in *.test.jh file", () => {
- const source = [
- 'import "workflow.jh" as w',
- '',
- 'test "runs default" {',
- ' const response = run w.default()',
- ' expect_contain response "PASS"',
- "}",
- "",
- ].join("\n");
- const mod = parsejaiph(source, "/fake/workflow.test.jh");
- assert.ok(mod.tests);
- assert.equal(mod.tests!.length, 1);
- assert.equal(mod.tests![0].description, "runs default");
- assert.equal(mod.tests![0].steps.length, 2);
- assert.equal(mod.tests![0].steps[0].type, "test_run_workflow");
- if (mod.tests![0].steps[0].type === "test_run_workflow") {
- assert.equal(mod.tests![0].steps[0].captureName, "response");
- assert.equal(mod.tests![0].steps[0].workflowRef, "w.default");
- }
- assert.equal(mod.tests![0].steps[1].type, "test_expect_contain");
- if (mod.tests![0].steps[1].type === "test_expect_contain") {
- assert.equal(mod.tests![0].steps[1].variable, "response");
- assert.equal(mod.tests![0].steps[1].substring, "PASS");
- }
-});
-
-test("parser parses mock workflow, rule, and script in test block", () => {
- const source = [
- 'import "app.jh" as app',
- "",
- 'test "isolated orchestration" {',
- " mock workflow app.build() {",
- ' log "build ok"',
- ' return "done"',
- " }",
- "",
- " mock rule app.policy_check() {",
- ' return "blocked"',
- " }",
- "",
- " mock script app.changed_files() {",
- ' echo "a.ts"',
- ' echo "b.ts"',
- " }",
- "",
- " const out = run app.default()",
- ' expect_contain out "blocked"',
- "}",
- "",
- ].join("\n");
- const mod = parsejaiph(source, "/fake/app.test.jh");
- assert.ok(mod.tests);
- assert.equal(mod.tests!.length, 1);
- assert.equal(mod.tests![0].description, "isolated orchestration");
- const steps = mod.tests![0].steps;
- assert.equal(steps[0].type, "test_mock_workflow");
- if (steps[0].type === "test_mock_workflow") {
- assert.equal(steps[0].ref, "app.build");
- assert.deepEqual(steps[0].params, []);
- assert.equal(steps[0].steps.length, 2);
- }
- assert.equal(steps[1].type, "blank_line");
- assert.equal(steps[2].type, "test_mock_rule");
- if (steps[2].type === "test_mock_rule") {
- assert.equal(steps[2].ref, "app.policy_check");
- assert.deepEqual(steps[2].params, []);
- assert.equal(steps[2].steps.length, 1);
- }
- assert.equal(steps[3].type, "blank_line");
- assert.equal(steps[4].type, "test_mock_script");
- if (steps[4].type === "test_mock_script") {
- assert.equal(steps[4].ref, "app.changed_files");
- assert.ok(steps[4].body.includes('echo "a.ts"'));
- }
- assert.equal(steps[5].type, "blank_line");
- assert.equal(steps[6].type, "test_run_workflow");
- assert.equal(steps[7].type, "test_expect_contain");
-});
-
-test("parser ignores test keyword in non-test file", () => {
- const source = [
- "workflow default() {",
- ' echo "hello"',
- "}",
- "",
- ].join("\n");
- const mod = parsejaiph(source, "/fake/main.jh");
- assert.equal(mod.tests, undefined);
-});
-
-test("jaiph test runs *.test.jh with mock workflow, rule, and script", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-test-mock-symbols-"));
- try {
- writeFileSync(
- join(root, "app.jh"),
- [
- "script policy_check_impl = `echo real-policy`",
- "rule policy_check() {",
- " run policy_check_impl()",
- "}",
- "script changed_files = `echo real_files`",
- "script build_impl = \`\`\`",
- 'echo "real build"',
- "\`\`\`",
- "workflow build() {",
- " run build_impl()",
- "}",
- "workflow default() {",
- " ensure policy_check()",
- " run build()",
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "app.test.jh"),
- [
- 'import "app.jh" as app',
- "",
- 'test "isolated orchestration" {',
- " mock workflow app.build() {",
- ' log "build ok"',
- ' return "build ok"',
- " }",
- "",
- " mock rule app.policy_check() {",
- ' return "policy ok"',
- " }",
- "",
- " mock script app.changed_files() {",
- ' echo "a.ts"',
- ' echo "b.ts"',
- " }",
- "",
- " const out = run app.default()",
- ' expect_contain out "build ok"',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const result = spawnSync("node", [cliPath, "test", join(root, "app.test.jh")], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
- assert.equal(result.status, 0, result.stderr + "\n" + result.stdout);
- assert.match(result.stdout, /test\(s\) passed|PASS/);
- assert.match(result.stdout, /isolated orchestration/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("jaiph test runs *.test.jh file with mocks", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-native-test-"));
- try {
- writeFileSync(
- join(root, "flow.jh"),
- [
- "workflow default() {",
- ' prompt "please greet"',
- ' return "done"',
- "}",
- "",
- ].join("\n"),
- );
- writeFileSync(
- join(root, "flow.test.jh"),
- [
- 'import "flow.jh" as f',
- "",
- 'test "captures output" {',
- ' mock prompt "mocked"',
- " const out = run f.default()",
- ' expect_contain out "done"',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const result = spawnSync("node", [cliPath, "test", join(root, "flow.test.jh")], {
- encoding: "utf8",
- cwd: root,
- env: process.env,
- });
- assert.equal(result.status, 0, result.stderr + "\n" + result.stdout);
- assert.match(result.stdout, /test\(s\) passed|PASS/);
- assert.match(result.stdout, /captures output/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("walkTestFiles discovers *.test.jh in directory", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-walk-test-"));
- try {
- writeFileSync(join(root, "a.test.jh"), "test \"t\" { }\n");
- writeFileSync(join(root, "b.jh"), "workflow default() { }\n");
- const files = walkTestFiles(root);
- assert.equal(files.length, 1);
- assert.ok(files.some((f) => f.endsWith("a.test.jh")));
- assert.ok(!files.some((f) => f.endsWith("b.jh")));
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-// --- recover loop semantics ---
-
-test("recover: success on first attempt skips recover body", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-recover-pass-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "script ok_impl = `echo ok`",
- "workflow ok() {",
- " run ok_impl()",
- "}",
- "workflow default() {",
- ' run ok() recover(err) {',
- ' log "should not run"',
- ' }',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("recover: one repair loop before success", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-recover-repair-"));
- try {
- // Script that fails unless a marker file exists (created by the recover body)
- writeFileSync(
- join(root, "main.jh"),
- [
- "script check = `test -f .marker`",
- "workflow check_wf() {",
- " run check()",
- "}",
- "script fix_impl = `touch .marker`",
- "workflow fix() {",
- " run fix_impl()",
- "}",
- "workflow default() {",
- " run check_wf() recover(err) {",
- " run fix()",
- " }",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- assert.ok(existsSync(join(root, ".marker")), "repair body should have created marker");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("recover: retry limit exhaustion fails the workflow", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-recover-exhaust-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "config {",
- " run.recover_limit = 2",
- "}",
- "",
- "script always_fail = `exit 1`",
- "workflow failing() {",
- " run always_fail()",
- "}",
- "workflow default() {",
- ' run failing() recover(err) {',
- ' log "repair attempt"',
- ' }',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.notEqual(r.status, 0, "should fail after retry limit exhausted");
- const combined = r.stdout + r.stderr;
- assert.match(combined, /FAIL/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("recover: retry limit configurable via config", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-recover-limit-"));
- try {
- // Counter file incremented by recover body; check script reads and compares.
- writeFileSync(join(root, ".counter"), "0");
- writeFileSync(
- join(root, "main.jh"),
- [
- "config {",
- " run.recover_limit = 3",
- "}",
- "",
- "script count_impl = ```",
- 'count=$(cat .counter)',
- 'if [ "$count" -ge 3 ]; then exit 0; fi',
- "exit 1",
- "```",
- "workflow attempt_wf() {",
- " run count_impl()",
- "}",
- "script bump_impl = ```",
- 'count=$(cat .counter)',
- 'echo $(( count + 1 )) > .counter',
- "```",
- "workflow bump() {",
- " run bump_impl()",
- "}",
- "workflow default() {",
- " run attempt_wf() recover(err) {",
- " run bump()",
- " }",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-// ── Handle async model tests ──
-
-test("handle: const capture run async creates handle that resolves on read", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-capture-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- 'script echo_val = `echo "hello"`',
- "workflow greet() {",
- " run echo_val()",
- ' return "hello"',
- "}",
- "workflow default() {",
- " const h = run async greet()",
- ' log "${h}"',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: passing handle as arg to run forces resolution", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-resolve-arg-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "workflow producer() {",
- ' return "produced"',
- "}",
- "workflow consumer(val) {",
- ' log "${val}"',
- "}",
- "workflow default() {",
- " const h = run async producer()",
- " run consumer(h)",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: multi-handle join — multiple async handles passed into another call", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-multi-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "workflow make_a() {",
- ' return "A"',
- "}",
- "workflow make_b() {",
- ' return "B"',
- "}",
- "workflow combine(a, b) {",
- ' log "${a}-${b}"',
- "}",
- "workflow default() {",
- " const ha = run async make_a()",
- " const hb = run async make_b()",
- " run combine(ha, hb)",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: workflow exit joins unresolved handles without error", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-join-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- 'script noop = `echo "done"`',
- "workflow bg() {",
- " run noop()",
- "}",
- "workflow default() {",
- " const h = run async bg()",
- ' log "continuing"',
- " # h is never read — implicit join at exit",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: handles stored in separate vars and resolved when read", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-stored-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "workflow first() {",
- ' return "1"',
- "}",
- "workflow second() {",
- ' return "2"',
- "}",
- "workflow default() {",
- " const h1 = run async first()",
- " const h2 = run async second()",
- " # Both stored, not resolved yet",
- ' log "${h1}"',
- ' log "${h2}"',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: run async foo() recover — handle resolves to success after repair", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-recover-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "script check = `test -f .marker`",
- "workflow check_wf() {",
- " run check()",
- "}",
- "script fix_impl = `touch .marker`",
- "workflow fix() {",
- " run fix_impl()",
- "}",
- "workflow default() {",
- " run async check_wf() recover(err) {",
- " run fix()",
- " }",
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /PASS/);
- assert.ok(existsSync(join(root, ".marker")), "repair body should have created marker");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("handle: run async recover shares retry-limit semantics with non-async recover", () => {
- const root = mkdtempSync(join(tmpdir(), "jaiph-handle-recover-limit-"));
- try {
- writeFileSync(
- join(root, "main.jh"),
- [
- "config {",
- " run.recover_limit = 2",
- "}",
- "",
- "script always_fail = `exit 1`",
- "workflow failing() {",
- " run always_fail()",
- "}",
- "workflow default() {",
- ' run async failing() recover(err) {',
- ' log "repair attempt"',
- ' }',
- "}",
- "",
- ].join("\n"),
- );
- const cliPath = join(process.cwd(), "dist/src/cli.js");
- const r = spawnSync("node", [cliPath, "run", join(root, "main.jh")], {
- encoding: "utf8",
- cwd: root,
- env: { ...process.env, JAIPH_DOCKER_ENABLED: "false" },
- });
- assert.notEqual(r.status, 0, "should fail after retry limit exhausted");
- const combined = r.stdout + r.stderr;
- assert.match(combined, /FAIL/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
diff --git a/tsconfig.json b/tsconfig.json
index 534f3544..28e45b02 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,5 +12,5 @@
"sourceMap": true,
"inlineSources": true
},
- "include": ["src/**/*.ts", "test/**/*.ts"]
+ "include": ["src/**/*.ts", "integration/**/*.ts", "test-infra/**/*.ts"]
}