diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index bed261f..7d50cf8 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -8,7 +8,7 @@ flowchart LR
Job --> Orchestrator[Orchestrator]
Orchestrator --> Worker[Pi worker process]
Worker --> Runner[Pi SDK runner]
- Orchestrator --> Checks[Check runner]
+ Orchestrator --> Checks[Shell check runner]
Orchestrator --> Git[Git transaction]
Orchestrator --> State[Run state]
Docker[Docker executors] --> CLI
@@ -23,9 +23,10 @@ flowchart LR
| Module | Responsibility |
| --- | --- |
| `cli.ts` | Selects commands and execution environments, reports exit status and output locations. |
-| `job.ts` | Loads direct task text, plain text files, or restricted YAML and validates job fields. |
+| `job.ts` | Loads and validates task inputs, adds prompt and context files, and derives the progress identity. |
| `run-args.ts` | Parses CLI overrides, model selection, Pi configuration, and environment forwarding. |
-| `orchestrator.ts` | Owns the state machine, limits, checks, progress, commits, and terminal result. |
+| `orchestrator.ts` | Owns the state machine, limits, check decisions, progress, commits, and terminal result. |
+| `check-runner.ts` | Runs one shell check with output limits, credential redaction, timeout, and cancellation. |
| `pi-process-runner.ts`, `pi-worker.ts` | Isolate each CLI Pi iteration in a process that can be stopped at the run deadline. |
| `pi-runner.ts` | Creates one Pi SDK session per iteration and normalizes model events, output, errors, and cost. |
| `runner.ts` | Defines the small runner interface and the diagnostic dry-run implementation. |
@@ -35,8 +36,9 @@ flowchart LR
| `docker-clone-executor.ts` | Clones a branch into the sandbox and exports an inspectable binary patch plus run records. |
| `docker-env.ts` | Transfers only selected model, Pi, check, context, override, and provider environment settings. |
| `remote-executor.ts` | Dispatches GitHub Actions, correlates the run, waits for completion, and downloads artifacts. |
-| `init.ts`, `*-workflow.ts` | Generate the manual and issue/PR workflows without overwriting existing files; ignore local RalphWorks state. |
-| `workflow-execution.ts` | Shares the identical RalphWorks checkout/build steps and provider credential environment across scenario workflow templates. |
+| `init.ts` | Writes the manual and scenario workflow templates without replacing existing files; ignores local RalphWorks state. |
+| `remote-workflow.ts`, `*-workflow.ts` | Define the manual remote workflow and the issue/PR scenario templates. |
+| `workflow-execution.ts` | Shares RalphWorks checkout/build steps, provider credential environment, and checked patch artifact validation across scenario workflow templates. |
| `status.ts`, `trace.ts` | Read compact summaries from structured result and event files. |
## Core state machine
@@ -79,6 +81,14 @@ Every run defaults to a 30-minute wall-clock limit unless the job or CLI supplie
The Docker and remote adapters have a separate 120-minute default outer deadline for setup, agent execution, and result delivery. `--total-minutes` overrides it. A timed-out Docker adapter attempts to force-remove its named container. A timed-out remote wait requests cancellation of its Actions run and returns the run URL for follow-up.
+## Prompt and skill boundaries
+
+RalphWorks does not replace Pi's system prompt. Pi supplies its own system instructions and discovers skills from its configured agent and trusted project directories. RalphWorks sends each fresh Pi session a task prompt containing the job, iteration number, task text, bounded saved progress, and rules for checks, Git ownership, handoff, completion, and human input. `buildRalphPrompt` in `src/pi-runner.ts` is the source of truth for that text.
+
+Scenario workflows create task files before invoking the same loop. Their instructions and referenced repository, Issue, PR, diff, or feedback files become the task portion of the RalphWorks prompt; they are not separate system prompts. The `src/*-workflow.ts` templates are the source of truth for scenario wording. When an architecture workflow uses an external skill, it checks out a selected `mattpocock/skills` commit, installs the skill for Pi, and records the resolved commit in its artifact and Issue. Skill content can change between runs when the configured ref is a branch.
+
+The agent requests completion with the configured `…` value, or asks for a person with a final `…` handoff. RalphWorks interprets those signals and independently applies its checks and limits; prompt instructions alone do not establish that a task passed verification.
+
## Git ownership
RalphWorks owns commits when `commit: verified` is enabled:
diff --git a/docs/ISSUE_WORKFLOWS.md b/docs/ISSUE_WORKFLOWS.md
index 29ba9fd..207306e 100644
--- a/docs/ISSUE_WORKFLOWS.md
+++ b/docs/ISSUE_WORKFLOWS.md
@@ -2,6 +2,10 @@
This guide installs RalphWorks' label-driven GitHub Actions workflows in another repository. The workflows turn an issue into a checked draft PR, split and implement a PRD, promote issues when dependencies close, review a PR, apply feedback, update a PR branch, and propose architecture work. A maintainer still reviews and merges implementation PRs.
+Each model-backed workflow builds a task file from its trigger and relevant repository context, then runs it through the same RalphWorks loop. Its execution job records the result and artifacts; a separate delivery job checks them before writing to GitHub. Scenario task text is defined in the generated workflow templates, while the loop's shared prompt rules are described in [the architecture guide](ARCHITECTURE.md#prompt-and-skill-boundaries).
+
+Implementation delivery requires a completed run, passing checks in its final iteration, a nonempty patch, and the patch's base commit record. The delivery job also verifies that the target branch has not advanced before applying the patch.
+
## 1. Prepare the target repository
The target must have GitHub Issues, Pull Requests, and Actions enabled. The workflows run on GitHub-hosted Ubuntu runners with Node.js 24 and pnpm 10.13.1. Project dependencies are installed with pnpm, npm, or Yarn when the corresponding lockfile exists. Make sure the repository's own test and build commands run on Ubuntu.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 3923345..de5264f 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -291,6 +291,8 @@ When a remote run returns `needs_input`, read its `reason` and run URL. Update t
`ralphworks init` also creates eight label-driven workflows for issue implementation, PRD sub-issues, dependency queues, PR review and feedback, branch updates, and architecture proposals. They are separate from the `remote` command: successful implementation runs can push a branch and open or update a PR. Maintainers review and merge those PRs.
+Before an implementation workflow delivers a patch, it requires a completed run, passing checks from the final iteration, a nonempty patch, and its base commit record. Missing or failed artifacts stop delivery and leave the run available for inspection.
+
The queue workflow paginates through all open queued issues. If PRD splitting creates some sub-issues before delivery fails, re-run the failed job from the same Actions run to finish that proposal without duplicating matching sub-issues. See the [issue and PR workflow guide](ISSUE_WORKFLOWS.md#limits-failures-and-verification) for recovery steps.
The [issue and PR workflow guide](ISSUE_WORKFLOWS.md) covers installation, credentials, labels, each path, and retries. Existing workflow files are not overwritten by `init`.
diff --git a/src/check-runner.ts b/src/check-runner.ts
new file mode 100644
index 0000000..7678ff2
--- /dev/null
+++ b/src/check-runner.ts
@@ -0,0 +1,70 @@
+import { spawn } from "node:child_process";
+
+import { redactSensitiveValues } from "./redact.ts";
+
+export type CheckResult = {
+ command: string;
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+ timedOut?: boolean;
+ cancelled?: boolean;
+};
+
+export function runCheck(command: string, cwd: string, timeoutMs: number, signal?: AbortSignal): Promise {
+ return new Promise((resolveCheck) => {
+ const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ let timedOut = false;
+ let cancelled = false;
+ let error: Error | undefined;
+ let settled = false;
+ let killTimer: NodeJS.Timeout | undefined;
+ const killGroup = (signal: NodeJS.Signals) => {
+ try {
+ if (child.pid && process.platform !== "win32") process.kill(-child.pid, signal);
+ else child.kill(signal);
+ } catch (cause) {
+ if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) error = cause as Error;
+ }
+ };
+ const timer = setTimeout(() => {
+ timedOut = true;
+ killGroup("SIGTERM");
+ killTimer = setTimeout(() => killGroup("SIGKILL"), 1000);
+ }, timeoutMs);
+ const abort = () => {
+ cancelled = true;
+ clearTimeout(timer);
+ killGroup("SIGTERM");
+ killTimer ??= setTimeout(() => killGroup("SIGKILL"), 1000);
+ };
+ signal?.addEventListener("abort", abort, { once: true });
+ if (signal?.aborted) abort();
+ const capture = (chunk: Buffer, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout = (stdout + chunk.toString()).slice(-8192);
+ else stderr = (stderr + chunk.toString()).slice(-8192);
+ };
+ child.stdout.on("data", (chunk: Buffer) => capture(chunk, "stdout"));
+ child.stderr.on("data", (chunk: Buffer) => capture(chunk, "stderr"));
+ child.on("error", (cause) => {
+ error = cause;
+ });
+ child.on("close", (code) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (killTimer) clearTimeout(killTimer);
+ signal?.removeEventListener("abort", abort);
+ resolveCheck({
+ command,
+ exitCode: code ?? 1,
+ stdout: redactSensitiveValues(stdout),
+ stderr: redactSensitiveValues(error ? `${stderr}\n${error.message}`.trim() : stderr),
+ ...(timedOut ? { timedOut: true } : {}),
+ ...(cancelled ? { cancelled: true } : {}),
+ });
+ });
+ });
+}
diff --git a/src/init.ts b/src/init.ts
index 94c944b..4ca47ac 100644
--- a/src/init.ts
+++ b/src/init.ts
@@ -7,6 +7,7 @@ import { PR_REVIEW_WORKFLOW } from "./pr-review-workflow.ts";
import { PRD_IMPLEMENT_WORKFLOW } from "./prd-implement-workflow.ts";
import { PRD_SPLIT_WORKFLOW } from "./prd-split-workflow.ts";
import { QUEUE_WORKFLOW } from "./queue-workflow.ts";
+import { REMOTE_WORKFLOW } from "./remote-workflow.ts";
import { UPDATE_BRANCH_WORKFLOW } from "./update-branch-workflow.ts";
export type InitResult = {
@@ -29,128 +30,7 @@ const TEMPLATES: Template[] = [
{ path: ".github/workflows/ralphworks-pr-feedback.yml", content: PR_FEEDBACK_WORKFLOW },
{ path: ".github/workflows/ralphworks-update-branch.yml", content: UPDATE_BRANCH_WORKFLOW },
{ path: ".github/workflows/ralphworks-architecture.yml", content: ARCHITECTURE_WORKFLOW },
- {
- path: ".github/workflows/ralphworks.yml",
- content: [
- "name: RalphWorks",
- "",
- "on:",
- " workflow_dispatch:",
- " inputs:",
- " task:",
- " description: RalphWorks task file",
- " required: true",
- " default: ralphworks.yaml",
- " request_id:",
- " description: Dispatch correlation ID",
- " required: false",
- " resume_run_id:",
- " description: Previous RalphWorks run ID to continue",
- " required: false",
- "",
- "run-name: RalphWorks ${{ inputs.request_id }}",
- "",
- "jobs:",
- " ralph:",
- " runs-on: ubuntu-latest",
- " permissions:",
- " contents: read",
- " actions: read",
- " env:",
- " RALPH_TASK: ${{ inputs.task }}",
- " RALPH_RESUME_RUN: ${{ inputs.resume_run_id }}",
- " RALPHWORKS_MODEL: ${{ vars.RALPHWORKS_MODEL }}",
- " RALPHWORKS_REF: ${{ vars.RALPHWORKS_REF || 'v0.2.0' }}",
- " RALPHWORKS_SOURCE_REPO: ${{ vars.RALPHWORKS_SOURCE_REPO || 'OctopusGarage/ralphworks' }}",
- " ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}",
- " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
- " NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}",
- " ZAI_CODING_CN_API_KEY: ${{ secrets.ZAI_CODING_CN_API_KEY }}",
- " steps:",
- " - uses: actions/checkout@v4",
- " with:",
- " fetch-depth: 0",
- " - uses: pnpm/action-setup@v4",
- " with:",
- " version: 10.13.1",
- " - uses: actions/setup-node@v4",
- " with:",
- " node-version: 24",
- " - name: Checkout RalphWorks",
- " env:",
- " GH_TOKEN: ${{ secrets.RALPHWORKS_REPO_TOKEN }}",
- " run: |",
- ' [[ "$RALPHWORKS_SOURCE_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid RALPHWORKS_SOURCE_REPO" >&2; exit 1; }',
- ' if [ -n "$GH_TOKEN" ]; then gh repo clone "$RALPHWORKS_SOURCE_REPO" "$RUNNER_TEMP/ralphworks"',
- ' else git clone "https://github.com/${RALPHWORKS_SOURCE_REPO}.git" "$RUNNER_TEMP/ralphworks"; fi',
- ' if [ -n "$RALPHWORKS_REF" ]; then git -C "$RUNNER_TEMP/ralphworks" checkout --detach "$RALPHWORKS_REF"; fi',
- ' echo "RalphWorks commit: $(git -C "$RUNNER_TEMP/ralphworks" rev-parse HEAD)"',
- " - name: Build RalphWorks",
- ' run: pnpm --dir "$RUNNER_TEMP/ralphworks" install --frozen-lockfile && pnpm --dir "$RUNNER_TEMP/ralphworks" build',
- " - run: |",
- " if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile",
- " elif [ -f package-lock.json ]; then npm ci",
- " elif [ -f yarn.lock ]; then yarn install --frozen-lockfile",
- " fi",
- ' - run: echo "RALPH_BASE=$(git rev-parse HEAD)" >> "$GITHUB_ENV"',
- " - name: Restore previous remote run",
- " if: env.RALPH_RESUME_RUN != ''",
- " env:",
- " GH_TOKEN: ${{ github.token }}",
- " run: |",
- ' [[ "$RALPH_RESUME_RUN" =~ ^[1-9][0-9]*$ ]] || { echo "Invalid resume run ID" >&2; exit 1; }',
- ' RUN_META=$(gh run view "$RALPH_RESUME_RUN" --repo "$GITHUB_REPOSITORY" --json headSha,headBranch,event,workflowName)',
- ' [ "$(jq -r .event <<< "$RUN_META")" = workflow_dispatch ] || { echo "Resume source is not a manual workflow run" >&2; exit 1; }',
- ' [ "$(jq -r .workflowName <<< "$RUN_META")" = RalphWorks ] || { echo "Resume source is not a RalphWorks run" >&2; exit 1; }',
- ' [ "$(jq -r .headBranch <<< "$RUN_META")" = "$GITHUB_REF_NAME" ] || { echo "Resume source branch differs" >&2; exit 1; }',
- ' [ "$(jq -r .headSha <<< "$RUN_META")" = "$(git rev-parse HEAD)" ] || { echo "Branch changed since the previous run; review and apply its patch manually" >&2; exit 1; }',
- ' gh run download "$RALPH_RESUME_RUN" --repo "$GITHUB_REPOSITORY" --name ralphworks-result --dir "$RUNNER_TEMP/ralphworks-resume"',
- ' [ "$(cat "$RUNNER_TEMP/ralphworks-resume/export/task.txt")" = "$RALPH_TASK" ] || { echo "Resume source task differs" >&2; exit 1; }',
- ' [ -d "$RUNNER_TEMP/ralphworks-resume/progress" ] || { echo "Resume source has no progress artifact" >&2; exit 1; }',
- ' PATCH="$RUNNER_TEMP/ralphworks-resume/export/change.patch"',
- ' if [ -s "$PATCH" ]; then git apply --check "$PATCH" && git apply "$PATCH"; fi',
- ' mkdir -p .ralph/progress && cp -R "$RUNNER_TEMP/ralphworks-resume/progress/." .ralph/progress/',
- " - run: |",
- " git config user.name 'ralphworks[bot]'",
- " git config user.email '41898282+github-actions[bot]@users.noreply.github.com'",
- " - name: Prepare optional Pi model config",
- " run: |",
- " if [ -f .ralphworks/models.json ]; then",
- " mkdir -p ~/.pi/agent",
- " cp .ralphworks/models.json ~/.pi/agent/models.json",
- " fi",
- " - name: Run RalphWorks",
- " env:",
- " RALPHWORKS_AUTH_SECRET_NAME: ${{ vars.RALPHWORKS_AUTH_SECRET }}",
- " RALPHWORKS_AUTH_SECRET_VALUE: ${{ secrets[vars.RALPHWORKS_AUTH_SECRET] }}",
- " run: |",
- ' if [ -n "$RALPHWORKS_AUTH_SECRET_NAME" ]; then',
- ' [[ "$RALPHWORKS_AUTH_SECRET_NAME" =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "Invalid RALPHWORKS_AUTH_SECRET name" >&2; exit 1; }',
- ' [ -n "$RALPHWORKS_AUTH_SECRET_VALUE" ] || { echo "Configured provider secret is empty" >&2; exit 1; }',
- ' export "$RALPHWORKS_AUTH_SECRET_NAME=$RALPHWORKS_AUTH_SECRET_VALUE"',
- " fi",
- ' node "$RUNNER_TEMP/ralphworks/dist/cli.js" run "$RALPH_TASK" --executor host',
- " - name: Export inspectable patch",
- " if: always()",
- " run: |",
- " mkdir -p .ralph/export",
- ' printf "%s\\n" "$RALPH_TASK" > .ralph/export/task.txt',
- ' git -C "$RUNNER_TEMP/ralphworks" rev-parse HEAD > .ralph/export/ralphworks-commit.txt',
- " git add -N --all",
- ' git diff --binary "${RALPH_BASE:-HEAD}" -- . ":!.ralph" > .ralph/export/change.patch',
- " - uses: actions/upload-artifact@v4",
- " if: always()",
- " with:",
- " name: ralphworks-result",
- " path: |",
- " .ralph/export/change.patch",
- " .ralph/export/ralphworks-commit.txt",
- " .ralph/export/task.txt",
- " .ralph/runs/",
- " .ralph/progress/",
- "",
- ].join("\n"),
- },
+ { path: ".github/workflows/ralphworks.yml", content: REMOTE_WORKFLOW },
];
export async function initProject(cwd = process.cwd()): Promise {
diff --git a/src/issue-workflow.ts b/src/issue-workflow.ts
index a1b6f2e..49c474f 100644
--- a/src/issue-workflow.ts
+++ b/src/issue-workflow.ts
@@ -1,4 +1,4 @@
-import { failureComment, PROVIDER_CREDENTIAL_ENV } from "./workflow-execution.ts";
+import { checkedPatchArtifact, failureComment, PROVIDER_CREDENTIAL_ENV, RALPHWORKS_CHECKOUT_AND_BUILD } from "./workflow-execution.ts";
export const ISSUE_WORKFLOW = [
"name: RalphWorks Issue",
@@ -60,14 +60,7 @@ export const ISSUE_WORKFLOW = [
" run: |",
" set -euo pipefail",
' [[ "$RALPHWORKS_SOURCE_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 1',
- ' if [ -n "$SOURCE_TOKEN" ]; then',
- ' GH_TOKEN="$SOURCE_TOKEN" gh repo clone "$RALPHWORKS_SOURCE_REPO" "$RUNNER_TEMP/ralphworks"',
- " else",
- ' git clone "https://github.com/${RALPHWORKS_SOURCE_REPO}.git" "$RUNNER_TEMP/ralphworks"',
- " fi",
- ' git -C "$RUNNER_TEMP/ralphworks" checkout --detach "$RALPHWORKS_REF"',
- ' pnpm --dir "$RUNNER_TEMP/ralphworks" install --frozen-lockfile',
- ' pnpm --dir "$RUNNER_TEMP/ralphworks" build',
+ ...RALPHWORKS_CHECKOUT_AND_BUILD,
" - name: Prepare issue task and dependencies",
" env:",
" ISSUE_TITLE: ${{ github.event.issue.title }}",
@@ -141,10 +134,7 @@ export const ISSUE_WORKFLOW = [
" - name: Validate completed result",
" run: |",
" set -euo pipefail",
- ' result=$(find "$RUNNER_TEMP/ralph-result/runs" -name result.json -type f -print -quit)',
- ' [ -n "$result" ] || { echo "Missing RalphWorks result" >&2; exit 1; }',
- ' jq -e \'.status == "completed" and (.iterations as $last | [.checks[] | select(.iteration == $last)] | length > 0) and (.iterations as $last | [.checks[] | select(.iteration == $last and .exitCode != 0)] | length == 0)\' "$result" >/dev/null',
- ' [ -s "$RUNNER_TEMP/ralph-result/export/change.patch" ] || { echo "Completed task produced no patch" >&2; exit 1; }',
+ ...checkedPatchArtifact("ralph-result"),
" - uses: actions/checkout@v4",
" with:",
" persist-credentials: false",
diff --git a/src/job.ts b/src/job.ts
index e01c6e5..e8eeb02 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
-import { readFile } from "node:fs/promises";
-import { basename, extname } from "node:path";
+import { readdir, readFile, stat } from "node:fs/promises";
+import { basename, extname, isAbsolute, join, relative } from "node:path";
export type RalphJob = {
name: string;
@@ -17,7 +17,7 @@ export type RalphJob = {
commit: "none" | "verified";
};
-export function defaultProgressFile(name: string, task: string): string {
+function defaultProgressFile(name: string, task: string): string {
const slug =
name
.toLowerCase()
@@ -42,6 +42,52 @@ export async function loadJob(jobPath: string): Promise {
return parseJob(text);
}
+export async function prepareJob(job: RalphJob, cwd: string, contexts: string[]): Promise {
+ let task = job.task;
+ if (job.promptFile) task += `\n\n${await readFile(resolveInputPath(cwd, job.promptFile), "utf8")}`;
+ if (contexts.length) task += await loadContexts(cwd, contexts);
+ return {
+ ...job,
+ task,
+ progressFile: job.progressFile === defaultProgressFile(job.name, job.task) ? defaultProgressFile(job.name, task) : job.progressFile,
+ };
+}
+
+async function loadContexts(cwd: string, sources: string[]): Promise {
+ const files: string[] = [];
+ for (const source of sources) {
+ const absolute = resolveInputPath(cwd, source);
+ const info = await stat(absolute);
+ if (info.isFile()) files.push(absolute);
+ else if (info.isDirectory()) await collectContextFiles(absolute, files);
+ else throw new Error(`Context source must be a file or directory: ${source}`);
+ }
+ if (files.length > 100) throw new Error("Context sources contain more than 100 files; narrow the context paths");
+ let total = 0;
+ const sections: string[] = [];
+ for (const file of files.sort()) {
+ const content = await readFile(file);
+ if (content.includes(0)) continue;
+ total += content.length;
+ if (total > 1024 * 1024) throw new Error("Context sources exceed 1 MiB; narrow the context paths");
+ sections.push(`\n\nContext file: ${relative(cwd, file)}\n\n${content.toString("utf8")}`);
+ }
+ return sections.join("");
+}
+
+async function collectContextFiles(directory: string, files: string[]): Promise {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ if ([".git", ".ralph", "node_modules"].includes(entry.name)) continue;
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) await collectContextFiles(path, files);
+ else if (entry.isFile()) files.push(path);
+ }
+}
+
+function resolveInputPath(cwd: string, path: string): string {
+ return isAbsolute(path) ? path : join(cwd, path);
+}
+
export function parseJob(text: string): RalphJob {
const raw = parseSimpleYaml(text);
const name = stringValue(raw.name, "name");
diff --git a/src/orchestrator.ts b/src/orchestrator.ts
index c43e189..047bf7f 100644
--- a/src/orchestrator.ts
+++ b/src/orchestrator.ts
@@ -1,9 +1,9 @@
-import { spawn } from "node:child_process";
-import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
-import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
+import { type CheckResult, runCheck } from "./check-runner.ts";
import { commitVerifiedChanges, currentGitHead, verifyCleanGitWorkspace, workspaceFingerprint } from "./git-transaction.ts";
-import { defaultProgressFile, loadJob, type RalphJob } from "./job.ts";
+import { loadJob, prepareJob, type RalphJob } from "./job.ts";
import { redactSensitiveValues } from "./redact.ts";
import type { JobOverrides } from "./run-args.ts";
import { withRunLock } from "./run-lock.ts";
@@ -52,7 +52,7 @@ export async function runLocalJob(jobPath: string, options: RunOptions = {}): Pr
async function runLocalJobUnlocked(jobPath: string, options: RunOptions, cwd: string): Promise {
const resolvedJobPath = resolvePath(cwd, jobPath);
- const job = await loadJob((await exists(resolvedJobPath)) ? resolvedJobPath : jobPath);
+ let job = await loadJob((await exists(resolvedJobPath)) ? resolvedJobPath : jobPath);
if (options.checksOverride) job.checks = options.checksOverride;
applyJobOverrides(job, options.jobOverrides);
job.maxMinutes ??= 30;
@@ -75,14 +75,7 @@ async function runLocalJobUnlocked(jobPath: string, options: RunOptions, cwd: st
let reason: string | undefined;
let lastSummary: string | undefined;
try {
- const originalTask = job.task;
- if (job.promptFile) {
- job.task += `\n\n${await readFile(resolvePath(cwd, job.promptFile), "utf8")}`;
- }
- if (options.contexts?.length) job.task += await loadContexts(cwd, options.contexts);
- if (job.progressFile === defaultProgressFile(job.name, originalTask)) {
- job.progressFile = defaultProgressFile(job.name, job.task);
- }
+ job = await prepareJob(job, cwd, options.contexts ?? []);
await ensureProgressFile(cwd, job);
await appendEvent(eventsPath, event("run_started", job));
const deadline = job.maxMinutes === undefined ? undefined : Date.now() + job.maxMinutes * 60_000;
@@ -381,37 +374,6 @@ async function exists(path: string): Promise {
);
}
-async function loadContexts(cwd: string, sources: string[]): Promise {
- const files: string[] = [];
- for (const source of sources) {
- const absolute = resolvePath(cwd, source);
- const info = await stat(absolute);
- if (info.isFile()) files.push(absolute);
- else if (info.isDirectory()) await collectContextFiles(absolute, files);
- else throw new Error(`Context source must be a file or directory: ${source}`);
- }
- if (files.length > 100) throw new Error("Context sources contain more than 100 files; narrow the context paths");
- let total = 0;
- const sections: string[] = [];
- for (const file of files.sort()) {
- const content = await readFile(file);
- if (content.includes(0)) continue;
- total += content.length;
- if (total > 1024 * 1024) throw new Error("Context sources exceed 1 MiB; narrow the context paths");
- sections.push(`\n\nContext file: ${relative(cwd, file)}\n\n${content.toString("utf8")}`);
- }
- return sections.join("");
-}
-
-async function collectContextFiles(directory: string, files: string[]): Promise {
- for (const entry of await readdir(directory, { withFileTypes: true })) {
- if ([".git", ".ralph", "node_modules"].includes(entry.name)) continue;
- const path = join(directory, entry.name);
- if (entry.isDirectory()) await collectContextFiles(path, files);
- else if (entry.isFile()) files.push(path);
- }
-}
-
function applyJobOverrides(job: RalphJob, overrides: JobOverrides | undefined): void {
if (!overrides) return;
if (overrides.maxIterations !== undefined) job.maxIterations = overrides.maxIterations;
@@ -422,77 +384,10 @@ function applyJobOverrides(job: RalphJob, overrides: JobOverrides | undefined):
if (overrides.completionPromise !== undefined) job.completionPromise = overrides.completionPromise;
}
-type CheckResult = {
- command: string;
- exitCode: number;
- stdout: string;
- stderr: string;
- timedOut?: boolean;
- cancelled?: boolean;
-};
-
type CheckRecord = CheckResult & {
iteration: number;
};
-function runCheck(command: string, cwd: string, timeoutMs: number, signal?: AbortSignal): Promise {
- return new Promise((resolveCheck) => {
- const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
- let stdout = "";
- let stderr = "";
- let timedOut = false;
- let cancelled = false;
- let error: Error | undefined;
- let settled = false;
- let killTimer: NodeJS.Timeout | undefined;
- const killGroup = (signal: NodeJS.Signals) => {
- try {
- if (child.pid && process.platform !== "win32") process.kill(-child.pid, signal);
- else child.kill(signal);
- } catch (cause) {
- if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) error = cause as Error;
- }
- };
- const timer = setTimeout(() => {
- timedOut = true;
- killGroup("SIGTERM");
- killTimer = setTimeout(() => killGroup("SIGKILL"), 1000);
- }, timeoutMs);
- const abort = () => {
- cancelled = true;
- clearTimeout(timer);
- killGroup("SIGTERM");
- killTimer ??= setTimeout(() => killGroup("SIGKILL"), 1000);
- };
- signal?.addEventListener("abort", abort, { once: true });
- if (signal?.aborted) abort();
- const capture = (chunk: Buffer, stream: "stdout" | "stderr") => {
- if (stream === "stdout") stdout = (stdout + chunk.toString()).slice(-8192);
- else stderr = (stderr + chunk.toString()).slice(-8192);
- };
- child.stdout.on("data", (chunk: Buffer) => capture(chunk, "stdout"));
- child.stderr.on("data", (chunk: Buffer) => capture(chunk, "stderr"));
- child.on("error", (cause) => {
- error = cause;
- });
- child.on("close", (code) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- if (killTimer) clearTimeout(killTimer);
- signal?.removeEventListener("abort", abort);
- resolveCheck({
- command,
- exitCode: code ?? 1,
- stdout: redactSensitiveValues(stdout),
- stderr: redactSensitiveValues(error ? `${stderr}\n${error.message}`.trim() : stderr),
- ...(timedOut ? { timedOut: true } : {}),
- ...(cancelled ? { cancelled: true } : {}),
- });
- });
- });
-}
-
function completionPromiseSatisfied(completionPromise: string | undefined, output: string): boolean {
if (!completionPromise) {
return false;
diff --git a/src/pr-feedback-workflow.ts b/src/pr-feedback-workflow.ts
index a4ca373..eecdc9d 100644
--- a/src/pr-feedback-workflow.ts
+++ b/src/pr-feedback-workflow.ts
@@ -1,4 +1,4 @@
-import { failureComment, PROVIDER_CREDENTIAL_ENV, RALPHWORKS_CHECKOUT_AND_BUILD } from "./workflow-execution.ts";
+import { checkedPatchArtifact, failureComment, PROVIDER_CREDENTIAL_ENV, RALPHWORKS_CHECKOUT_AND_BUILD } from "./workflow-execution.ts";
export const PR_FEEDBACK_WORKFLOW = [
"name: RalphWorks PR Feedback",
@@ -117,10 +117,7 @@ export const PR_FEEDBACK_WORKFLOW = [
" - name: Validate result",
" run: |",
" set -euo pipefail",
- ' result=$(find "$RUNNER_TEMP/feedback/runs" -name result.json -type f -print -quit)',
- ' [ -n "$result" ] || exit 1',
- ' jq -e \'.status == "completed" and (.iterations as $last | [.checks[] | select(.iteration == $last)] | length > 0) and (.iterations as $last | [.checks[] | select(.iteration == $last and .exitCode != 0)] | length == 0)\' "$result" >/dev/null',
- ' [ -s "$RUNNER_TEMP/feedback/export/change.patch" ] || exit 1',
+ ...checkedPatchArtifact("feedback"),
" - uses: actions/checkout@v4",
" with:",
" ref: ${{ github.event.pull_request.head.sha }}",
diff --git a/src/prd-implement-workflow.ts b/src/prd-implement-workflow.ts
index 1d333f3..7716c81 100644
--- a/src/prd-implement-workflow.ts
+++ b/src/prd-implement-workflow.ts
@@ -1,4 +1,4 @@
-import { failureComment, PROVIDER_CREDENTIAL_ENV, RALPHWORKS_CHECKOUT_AND_BUILD } from "./workflow-execution.ts";
+import { checkedPatchArtifact, failureComment, PROVIDER_CREDENTIAL_ENV, RALPHWORKS_CHECKOUT_AND_BUILD } from "./workflow-execution.ts";
export const PRD_IMPLEMENT_WORKFLOW = [
"name: RalphWorks Implement PRD",
@@ -145,10 +145,7 @@ export const PRD_IMPLEMENT_WORKFLOW = [
" - name: Validate result",
" run: |",
" set -euo pipefail",
- ' result=$(find "$RUNNER_TEMP/prd-result/runs" -name result.json -type f -print -quit)',
- ' [ -n "$result" ] || exit 1',
- ' jq -e \'.status == "completed" and (.iterations as $last | [.checks[] | select(.iteration == $last)] | length > 0) and (.iterations as $last | [.checks[] | select(.iteration == $last and .exitCode != 0)] | length == 0)\' "$result" >/dev/null',
- ' [ -s "$RUNNER_TEMP/prd-result/export/change.patch" ] || exit 1',
+ ...checkedPatchArtifact("prd-result"),
" - uses: actions/checkout@v4",
" with:",
" ref: ${{ steps.branch.outputs.ref }}",
diff --git a/src/remote-workflow.ts b/src/remote-workflow.ts
new file mode 100644
index 0000000..528e68e
--- /dev/null
+++ b/src/remote-workflow.ts
@@ -0,0 +1,119 @@
+export const REMOTE_WORKFLOW = [
+ "name: RalphWorks",
+ "",
+ "on:",
+ " workflow_dispatch:",
+ " inputs:",
+ " task:",
+ " description: RalphWorks task file",
+ " required: true",
+ " default: ralphworks.yaml",
+ " request_id:",
+ " description: Dispatch correlation ID",
+ " required: false",
+ " resume_run_id:",
+ " description: Previous RalphWorks run ID to continue",
+ " required: false",
+ "",
+ "run-name: RalphWorks ${{ inputs.request_id }}",
+ "",
+ "jobs:",
+ " ralph:",
+ " runs-on: ubuntu-latest",
+ " permissions:",
+ " contents: read",
+ " actions: read",
+ " env:",
+ " RALPH_TASK: ${{ inputs.task }}",
+ " RALPH_RESUME_RUN: ${{ inputs.resume_run_id }}",
+ " RALPHWORKS_MODEL: ${{ vars.RALPHWORKS_MODEL }}",
+ " RALPHWORKS_REF: ${{ vars.RALPHWORKS_REF || 'v0.2.0' }}",
+ " RALPHWORKS_SOURCE_REPO: ${{ vars.RALPHWORKS_SOURCE_REPO || 'OctopusGarage/ralphworks' }}",
+ " ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}",
+ " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
+ " NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}",
+ " ZAI_CODING_CN_API_KEY: ${{ secrets.ZAI_CODING_CN_API_KEY }}",
+ " steps:",
+ " - uses: actions/checkout@v4",
+ " with:",
+ " fetch-depth: 0",
+ " - uses: pnpm/action-setup@v4",
+ " with:",
+ " version: 10.13.1",
+ " - uses: actions/setup-node@v4",
+ " with:",
+ " node-version: 24",
+ " - name: Checkout RalphWorks",
+ " env:",
+ " GH_TOKEN: ${{ secrets.RALPHWORKS_REPO_TOKEN }}",
+ " run: |",
+ ' [[ "$RALPHWORKS_SOURCE_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid RALPHWORKS_SOURCE_REPO" >&2; exit 1; }',
+ ' if [ -n "$GH_TOKEN" ]; then gh repo clone "$RALPHWORKS_SOURCE_REPO" "$RUNNER_TEMP/ralphworks"',
+ ' else git clone "https://github.com/${RALPHWORKS_SOURCE_REPO}.git" "$RUNNER_TEMP/ralphworks"; fi',
+ ' if [ -n "$RALPHWORKS_REF" ]; then git -C "$RUNNER_TEMP/ralphworks" checkout --detach "$RALPHWORKS_REF"; fi',
+ ' echo "RalphWorks commit: $(git -C "$RUNNER_TEMP/ralphworks" rev-parse HEAD)"',
+ " - name: Build RalphWorks",
+ ' run: pnpm --dir "$RUNNER_TEMP/ralphworks" install --frozen-lockfile && pnpm --dir "$RUNNER_TEMP/ralphworks" build',
+ " - run: |",
+ " if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile",
+ " elif [ -f package-lock.json ]; then npm ci",
+ " elif [ -f yarn.lock ]; then yarn install --frozen-lockfile",
+ " fi",
+ ' - run: echo "RALPH_BASE=$(git rev-parse HEAD)" >> "$GITHUB_ENV"',
+ " - name: Restore previous remote run",
+ " if: env.RALPH_RESUME_RUN != ''",
+ " env:",
+ " GH_TOKEN: ${{ github.token }}",
+ " run: |",
+ ' [[ "$RALPH_RESUME_RUN" =~ ^[1-9][0-9]*$ ]] || { echo "Invalid resume run ID" >&2; exit 1; }',
+ ' RUN_META=$(gh run view "$RALPH_RESUME_RUN" --repo "$GITHUB_REPOSITORY" --json headSha,headBranch,event,workflowName)',
+ ' [ "$(jq -r .event <<< "$RUN_META")" = workflow_dispatch ] || { echo "Resume source is not a manual workflow run" >&2; exit 1; }',
+ ' [ "$(jq -r .workflowName <<< "$RUN_META")" = RalphWorks ] || { echo "Resume source is not a RalphWorks run" >&2; exit 1; }',
+ ' [ "$(jq -r .headBranch <<< "$RUN_META")" = "$GITHUB_REF_NAME" ] || { echo "Resume source branch differs" >&2; exit 1; }',
+ ' [ "$(jq -r .headSha <<< "$RUN_META")" = "$(git rev-parse HEAD)" ] || { echo "Branch changed since the previous run; review and apply its patch manually" >&2; exit 1; }',
+ ' gh run download "$RALPH_RESUME_RUN" --repo "$GITHUB_REPOSITORY" --name ralphworks-result --dir "$RUNNER_TEMP/ralphworks-resume"',
+ ' [ "$(cat "$RUNNER_TEMP/ralphworks-resume/export/task.txt")" = "$RALPH_TASK" ] || { echo "Resume source task differs" >&2; exit 1; }',
+ ' [ -d "$RUNNER_TEMP/ralphworks-resume/progress" ] || { echo "Resume source has no progress artifact" >&2; exit 1; }',
+ ' PATCH="$RUNNER_TEMP/ralphworks-resume/export/change.patch"',
+ ' if [ -s "$PATCH" ]; then git apply --check "$PATCH" && git apply "$PATCH"; fi',
+ ' mkdir -p .ralph/progress && cp -R "$RUNNER_TEMP/ralphworks-resume/progress/." .ralph/progress/',
+ " - run: |",
+ " git config user.name 'ralphworks[bot]'",
+ " git config user.email '41898282+github-actions[bot]@users.noreply.github.com'",
+ " - name: Prepare optional Pi model config",
+ " run: |",
+ " if [ -f .ralphworks/models.json ]; then",
+ " mkdir -p ~/.pi/agent",
+ " cp .ralphworks/models.json ~/.pi/agent/models.json",
+ " fi",
+ " - name: Run RalphWorks",
+ " env:",
+ " RALPHWORKS_AUTH_SECRET_NAME: ${{ vars.RALPHWORKS_AUTH_SECRET }}",
+ " RALPHWORKS_AUTH_SECRET_VALUE: ${{ secrets[vars.RALPHWORKS_AUTH_SECRET] }}",
+ " run: |",
+ ' if [ -n "$RALPHWORKS_AUTH_SECRET_NAME" ]; then',
+ ' [[ "$RALPHWORKS_AUTH_SECRET_NAME" =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "Invalid RALPHWORKS_AUTH_SECRET name" >&2; exit 1; }',
+ ' [ -n "$RALPHWORKS_AUTH_SECRET_VALUE" ] || { echo "Configured provider secret is empty" >&2; exit 1; }',
+ ' export "$RALPHWORKS_AUTH_SECRET_NAME=$RALPHWORKS_AUTH_SECRET_VALUE"',
+ " fi",
+ ' node "$RUNNER_TEMP/ralphworks/dist/cli.js" run "$RALPH_TASK" --executor host',
+ " - name: Export inspectable patch",
+ " if: always()",
+ " run: |",
+ " mkdir -p .ralph/export",
+ ' printf "%s\\n" "$RALPH_TASK" > .ralph/export/task.txt',
+ ' git -C "$RUNNER_TEMP/ralphworks" rev-parse HEAD > .ralph/export/ralphworks-commit.txt',
+ " git add -N --all",
+ ' git diff --binary "${RALPH_BASE:-HEAD}" -- . ":!.ralph" > .ralph/export/change.patch',
+ " - uses: actions/upload-artifact@v4",
+ " if: always()",
+ " with:",
+ " name: ralphworks-result",
+ " path: |",
+ " .ralph/export/change.patch",
+ " .ralph/export/ralphworks-commit.txt",
+ " .ralph/export/task.txt",
+ " .ralph/runs/",
+ " .ralph/progress/",
+ "",
+].join("\n");
diff --git a/src/workflow-execution.ts b/src/workflow-execution.ts
index 0fa33c1..c7546f9 100644
--- a/src/workflow-execution.ts
+++ b/src/workflow-execution.ts
@@ -16,6 +16,17 @@ export const RALPHWORKS_CHECKOUT_AND_BUILD = [
' pnpm --dir "$RUNNER_TEMP/ralphworks" build',
] as const;
+export function checkedPatchArtifact(artifact: string): string[] {
+ const root = `$RUNNER_TEMP/${artifact}`;
+ return [
+ ` result=$(find "${root}/runs" -name result.json -type f -print -quit)`,
+ ' [ -n "$result" ] || { echo "Missing RalphWorks result" >&2; exit 1; }',
+ ' jq -e \'.status == "completed" and (.iterations as $last | [.checks[] | select(.iteration == $last)] | length > 0) and (.iterations as $last | [.checks[] | select(.iteration == $last and .exitCode != 0)] | length == 0)\' "$result" >/dev/null',
+ ` [ -s "${root}/export/change.patch" ] || { echo "Completed task produced no patch" >&2; exit 1; }`,
+ ` [ -s "${root}/export/base-sha.txt" ] || { echo "Missing patch base commit" >&2; exit 1; }`,
+ ];
+}
+
export function failureComment(
artifact: string,
targetNumber: string,
diff --git a/tests/job.test.ts b/tests/job.test.ts
index 4391c97..dcd9bcb 100644
--- a/tests/job.test.ts
+++ b/tests/job.test.ts
@@ -1,10 +1,36 @@
import assert from "node:assert/strict";
-import { mkdtemp, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
-import { loadJob, parseJob } from "../src/job.ts";
+import { loadJob, parseJob, prepareJob } from "../src/job.ts";
+
+test("prepareJob assembles prompt and context before identifying default progress", async () => {
+ const workspace = await mkdtemp(join(tmpdir(), "ralphworks-job-input-"));
+ const contextDir = join(workspace, "context");
+ await mkdir(contextDir);
+ await writeFile(join(workspace, "prompt.md"), "Additional acceptance criteria.");
+ await writeFile(join(contextDir, "notes.md"), "Useful repository context.");
+ await writeFile(join(contextDir, "binary.dat"), Buffer.from([0, 1, 2]));
+ const original = parseJob("name: input-task\ntask: Implement the change.\nprompt_file: prompt.md\n");
+
+ const prepared = await prepareJob(original, workspace, [contextDir]);
+
+ assert.equal(original.task, "Implement the change.");
+ assert.match(prepared.task, /^Implement the change\.\n\nAdditional acceptance criteria\./);
+ assert.match(prepared.task, /Context file: context\/notes\.md\n\nUseful repository context\./);
+ assert.doesNotMatch(prepared.task, /binary\.dat/);
+ assert.notEqual(prepared.progressFile, original.progressFile);
+ assert.equal(prepared.progressFile, (await prepareJob(original, workspace, [contextDir])).progressFile);
+});
+
+test("prepareJob preserves an explicit progress file", async () => {
+ const workspace = await mkdtemp(join(tmpdir(), "ralphworks-job-input-"));
+ const original = parseJob("name: shared-task\ntask: Do it.\nprogress_file: .ralph/progress/shared.md\n");
+
+ assert.equal((await prepareJob(original, workspace, [])).progressFile, ".ralph/progress/shared.md");
+});
test("jobs keep separate default progress files", () => {
const first = parseJob("name: first-task\ntask: Do first.\n");
diff --git a/tests/workflow-scenarios.test.ts b/tests/workflow-scenarios.test.ts
index 8c6e6a3..2aa3cee 100644
--- a/tests/workflow-scenarios.test.ts
+++ b/tests/workflow-scenarios.test.ts
@@ -1,17 +1,69 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
-import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { ISSUE_WORKFLOW } from "../src/issue-workflow.ts";
+import { PR_FEEDBACK_WORKFLOW } from "../src/pr-feedback-workflow.ts";
+import { PRD_IMPLEMENT_WORKFLOW } from "../src/prd-implement-workflow.ts";
import { PRD_SPLIT_WORKFLOW } from "../src/prd-split-workflow.ts";
import { QUEUE_WORKFLOW } from "../src/queue-workflow.ts";
const run = promisify(execFile);
+for (const { name, workflow, step, artifact } of [
+ { name: "issue", workflow: ISSUE_WORKFLOW, step: "Validate completed result", artifact: "ralph-result" },
+ { name: "PRD implementation", workflow: PRD_IMPLEMENT_WORKFLOW, step: "Validate result", artifact: "prd-result" },
+ { name: "PR feedback", workflow: PR_FEEDBACK_WORKFLOW, step: "Validate result", artifact: "feedback" },
+]) {
+ test(`${name} delivery accepts only a completed, checked patch artifact`, async () => {
+ const directory = await mkdtemp(join(tmpdir(), "ralphworks-delivery-"));
+ const artifactDir = join(directory, artifact);
+ const resultPath = join(artifactDir, "runs", "run-1", "result.json");
+ const patchPath = join(artifactDir, "export", "change.patch");
+ const basePath = join(artifactDir, "export", "base-sha.txt");
+ const script = stepScript(workflow, step);
+ const env = { ...process.env, RUNNER_TEMP: directory };
+ const completed = { status: "completed", iterations: 2, checks: [{ iteration: 2, exitCode: 0 }] };
+ await mkdir(join(artifactDir, "runs", "run-1"), { recursive: true });
+ await mkdir(join(artifactDir, "export"), { recursive: true });
+
+ try {
+ await writeFile(patchPath, "nonempty patch\n");
+ await writeFile(basePath, "0123456789abcdef\n");
+ for (const { label, result, patch, base } of [
+ { label: "valid", result: completed, patch: true, base: true },
+ { label: "nonterminal", result: { ...completed, status: "needs_input" }, patch: true, base: true },
+ {
+ label: "checks from an earlier iteration",
+ result: { ...completed, checks: [{ iteration: 1, exitCode: 0 }] },
+ patch: true,
+ base: true,
+ },
+ { label: "failed final check", result: { ...completed, checks: [{ iteration: 2, exitCode: 1 }] }, patch: true, base: true },
+ { label: "missing run result", result: null, patch: true, base: true },
+ { label: "malformed run result", result: "{not json", patch: true, base: true },
+ { label: "empty patch", result: completed, patch: false, base: true },
+ { label: "missing base commit", result: completed, patch: true, base: false },
+ ]) {
+ if (result === null) await rm(resultPath, { force: true });
+ else await writeFile(resultPath, typeof result === "string" ? result : JSON.stringify(result));
+ await writeFile(patchPath, patch ? "nonempty patch\n" : "");
+ if (base) await writeFile(basePath, "0123456789abcdef\n");
+ else await rm(basePath, { force: true });
+ const execution = run("bash", ["-e", "-c", script], { env });
+ if (label === "valid") await execution;
+ else await assert.rejects(execution, `${name} accepted ${label}`);
+ }
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+}
+
test("issue failure reports the human question from a run artifact", async () => {
const directory = await mkdtemp(join(tmpdir(), "ralphworks-feedback-"));
const bin = join(directory, "bin");