From 7395b432c648b69910e4265399f61aec847dfa54 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Wed, 1 Apr 2026 16:38:06 +0200 Subject: [PATCH 01/71] fix: workflow timout --- README.md | 24 +- .../2026-04-01-sandbox-polling-suspension.md | 889 ++++++++++++++++++ env.ts | 7 - src/adapters/run-registry/upstash.test.ts | 3 +- src/adapters/run-registry/upstash.ts | 5 +- src/lib/reconcile.test.ts | 49 +- src/lib/reconcile.ts | 25 + src/sandbox/agent-runner.ts | 57 -- src/sandbox/credentials.ts | 20 + src/sandbox/manager.test.ts | 54 -- src/sandbox/manager.ts | 100 +- src/sandbox/poll-agent.test.ts | 108 +++ src/sandbox/poll-agent.ts | 112 +++ src/sandbox/run-agent.ts | 89 +- src/sandbox/wrapper-script.test.ts | 22 + src/sandbox/wrapper-script.ts | 46 + src/workflows/implementation.ts | 114 ++- src/workflows/review-fix.ts | 99 +- 18 files changed, 1454 insertions(+), 369 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-01-sandbox-polling-suspension.md create mode 100644 src/sandbox/credentials.ts create mode 100644 src/sandbox/poll-agent.test.ts create mode 100644 src/sandbox/poll-agent.ts create mode 100644 src/sandbox/wrapper-script.test.ts create mode 100644 src/sandbox/wrapper-script.ts diff --git a/README.md b/README.md index 2b9e385..b424a0c 100644 --- a/README.md +++ b/README.md @@ -240,9 +240,6 @@ curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/cron/poll | `AI_WORKFLOW_KV_REST_API_TOKEN` | Yes | — | Upstash Redis REST token | | **Security** | | | | | `CRON_SECRET` | No | — | Cron endpoint auth token | -| **Debug** | | | | -| `DEBUG_AGENT` | No | `false` | Enable stream-json agent logging | - \* On Vercel, OIDC authenticates automatically. These are only needed for local development if `vercel env pull` doesn't cover your setup. ## Deploying to Vercel @@ -302,7 +299,9 @@ When a ticket is discovered in the AI column and no PR exists yet, the **impleme | `fetchAndValidateTicket` | Fetches ticket from Jira, verifies it's still in the AI column | | `createFeatureBranch` | Creates `blazebot/{ticket-key}` branch from the base branch | | `assembleImplementationRequirements` | Combines ticket title, description, acceptance criteria, and comments into a `requirements.md` prompt | -| `runAgentInSandbox` | Provisions a Vercel Sandbox, installs Claude Code + global skills, runs the agent with a JSON output schema | +| `provisionAndStartAgent` | Provisions a Vercel Sandbox, installs Claude Code + global skills, starts the agent detached with a JSON output schema | +| *poll loop* | Polls the sandbox every 30s for completion (workflow suspends between polls) | +| `collectAgentResults` | Reads agent output and extracts changed files from the sandbox | | `pushChanges` | Pushes all modified files to the feature branch via the GitHub API | | `createPullRequest` | Opens a PR targeting the base branch | | `moveTicket` | Moves the Jira ticket to the "AI Review" column | @@ -320,7 +319,9 @@ When a ticket is in the AI column but a PR already exists (indicating review fee | `fetchAndValidateTicket` | Same as implementation | | `fetchPRContext` | Fetches all PR comments (review + issue) and merge conflict status | | `assembleReviewFixRequirements` | Builds requirements including the original ticket context plus PR feedback and conflict status | -| `runFixingAgentInSandbox` | Runs the agent with the fixing prompt | +| `provisionAndStartFixingAgent` | Starts the agent detached with the fixing prompt | +| *poll loop* | Polls the sandbox every 30s for completion | +| `collectAgentResults` | Reads agent output and extracts changed files | | `pushChanges` | Pushes fixes to the existing branch | | `moveTicket` | Moves back to AI Review | | `notifySlack` | Notifies the team | @@ -348,7 +349,7 @@ The sandbox runs on **Node.js 24** with a configurable timeout (`JOB_TIMEOUT_MS` Claude Code is invoked inside the sandbox with: - `--dangerously-skip-permissions` — safe because the sandbox is fully isolated -- `--output-format json` — enforces structured output (or `stream-json` when `DEBUG_AGENT=true`) +- `--output-format json` — enforces structured output - `--json-schema '{...}'` — the agent must return output matching the schema below The agent reads `requirements.md` via stdin and implements the feature autonomously. It has access to the full repository, can run tests, install dependencies, and make commits. @@ -366,13 +367,12 @@ The agent must return structured output conforming to: #### How commits are extracted -The agent commits inside the sandbox like any developer. Before teardown, Blazebot runs an **end hook**: +The agent commits inside the sandbox via a **stop hook** that blocks exit until all changes are committed. A **wrapper script** runs the agent detached, cleans up artifacts (`.claude/`, `requirements.md`), and writes a sentinel file (`/tmp/agent-done`) on completion. The workflow polls for this sentinel every 30 seconds, then: -1. Checks `git status --porcelain` for uncommitted changes -2. If any exist, runs `git add -A` and `git commit` with a WIP message — this ensures no work is lost -3. Extracts changed files via `git diff --name-only HEAD~1 HEAD` -4. Reads each modified file's content from the sandbox (excluding `requirements.md`) -5. Returns the file list `Array<{ path, content }>` to the workflow +1. Reads agent stdout/stderr from `/tmp/agent-stdout.txt` and `/tmp/agent-stderr.txt` +2. Diffs against the pre-agent SHA to find changed files (`git diff --name-only`) +3. Reads each modified file's content from the sandbox (excluding `requirements.md` and `.claude/`) +4. Returns the file list `Array<{ path, content }>` to the workflow #### How changes get pushed to GitHub diff --git a/docs/superpowers/plans/2026-04-01-sandbox-polling-suspension.md b/docs/superpowers/plans/2026-04-01-sandbox-polling-suspension.md new file mode 100644 index 0000000..5f50ee5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-sandbox-polling-suspension.md @@ -0,0 +1,889 @@ +# Sandbox Polling Suspension Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Suspend the workflow while the sandbox runs the Claude Code agent, using a sleep+poll pattern so the workflow consumes zero resources during the 10-30 min agent execution. + +**Architecture:** Split the blocking `runAgentInSandbox` step into three phases: (1) provision sandbox + start agent detached, (2) poll for completion with `sleep("30s")` intervals (workflow truly suspends between polls), (3) collect results + teardown. A bash wrapper script inside the sandbox runs claude, does cleanup, and writes sentinel files. Debug mode (live log streaming via `getWritable`) is removed — the polling approach replaces it entirely. + +**Tech Stack:** Vercel Workflow DevKit (`sleep` from `"workflow"`), `@vercel/sandbox` (`Sandbox.get()` for reconnection), Nitro (h3 routes) + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `src/sandbox/wrapper-script.ts` | Generates the bash wrapper script for detached agent execution | +| Create | `src/sandbox/poll-agent.ts` | Step functions: `checkAgentDone`, `collectAgentResults`, `teardownSandbox` | +| Modify | `src/sandbox/manager.ts` | Extract `getSandboxCredentials()`, add wrapper script installation to `provision()` | +| Modify | `src/sandbox/run-agent.ts` | Replace `runAgent()` with `startAgentDetached()`; remove debug streaming code | +| Modify | `src/workflows/implementation.ts` | Replace single blocking step with provision → poll loop → collect pattern | +| Modify | `src/workflows/review-fix.ts` | Same poll pattern as implementation workflow | +| Create | `src/sandbox/wrapper-script.test.ts` | Test wrapper script generation | +| Create | `src/sandbox/poll-agent.test.ts` | Test polling and result collection | + +--- + +### Task 1: Extract sandbox credentials helper + +Move credential resolution from inline in `SandboxManager.provision()` into a reusable function, so both provisioning and reconnection steps can authenticate with the Sandbox API. + +**Files:** +- Create: `src/sandbox/credentials.ts` +- Modify: `src/sandbox/manager.ts:49-59` (use the new helper) + +- [ ] **Step 1: Create `src/sandbox/credentials.ts`** + +```ts +// src/sandbox/credentials.ts +import type { Sandbox as SandboxType } from "@vercel/sandbox"; + +type Credentials = { + token: string; + teamId: string; + projectId: string; +}; + +/** + * Returns explicit Sandbox credentials when all three env vars are set (local dev). + * On Vercel, returns empty object — the SDK authenticates via OIDC automatically. + */ +export function getSandboxCredentials(): Partial { + const token = process.env.VERCEL_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + const projectId = process.env.VERCEL_PROJECT_ID; + + if (token && teamId && projectId) { + return { token, teamId, projectId }; + } + return {}; +} +``` + +- [ ] **Step 2: Update `SandboxManager.provision()` to use the helper** + +In `src/sandbox/manager.ts`, replace the inline credential logic with the helper. + +Replace lines 43-59 (inside `provision()`): +```ts +// Before: +if (!this.config.claudeCodeOauthToken && !this.config.anthropicApiKey) { + throw new Error("Either anthropicApiKey or claudeCodeOauthToken must be provided"); +} + +const hasExplicitCredentials = + this.config.vercelToken && this.config.vercelTeamId && this.config.vercelProjectId; + +const sandbox = await Sandbox.create({ + ...(hasExplicitCredentials + ? { + token: this.config.vercelToken, + teamId: this.config.vercelTeamId, + projectId: this.config.vercelProjectId, + } + : {}), + // ... +}); +``` + +With: +```ts +// After: +import { getSandboxCredentials } from "./credentials.js"; + +if (!this.config.claudeCodeOauthToken && !this.config.anthropicApiKey) { + throw new Error("Either anthropicApiKey or claudeCodeOauthToken must be provided"); +} + +const sandbox = await Sandbox.create({ + ...getSandboxCredentials(), + // ... rest stays the same +}); +``` + +- [ ] **Step 3: Remove `vercelToken`, `vercelTeamId`, `vercelProjectId` from `SandboxConfig`** + +These are now read from `process.env` by `getSandboxCredentials()`. Remove them from the `SandboxConfig` interface and all call sites that pass them. + +In `src/sandbox/manager.ts`: +```ts +// Remove these three fields from SandboxConfig: +export interface SandboxConfig { + githubToken: string; + owner: string; + repo: string; + anthropicApiKey?: string; + claudeCodeOauthToken?: string; + claudeModel: string; + commitAuthor: string; + commitEmail: string; + jobTimeoutMs: number; + // REMOVE: vercelToken, vercelTeamId, vercelProjectId +} +``` + +In `src/workflows/implementation.ts` (`runAgentInSandbox` step), remove these three lines from the `SandboxManager` constructor: +```ts +// REMOVE: +vercelToken: env.VERCEL_TOKEN, +vercelTeamId: env.VERCEL_TEAM_ID, +vercelProjectId: env.VERCEL_PROJECT_ID, +``` + +Same removal in `src/workflows/review-fix.ts` (`runFixingAgentInSandbox` step). + +- [ ] **Step 4: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS (no type errors) + +- [ ] **Step 5: Run existing tests** + +Run: `pnpm test` +Expected: PASS (all existing tests pass) + +- [ ] **Step 6: Commit** + +```bash +git add src/sandbox/credentials.ts src/sandbox/manager.ts src/workflows/implementation.ts src/workflows/review-fix.ts +git commit -m "refactor: extract getSandboxCredentials into reusable helper" +``` + +--- + +### Task 2: Build the wrapper script generator + +Create a function that generates a bash script to run inside the sandbox. The script: runs claude (which commits via the stop hook), does artifact cleanup, and writes sentinel files signaling completion. The agent is responsible for committing — the wrapper does NOT auto-commit. + +**Files:** +- Create: `src/sandbox/wrapper-script.ts` +- Create: `src/sandbox/wrapper-script.test.ts` + +- [ ] **Step 1: Write failing test for `buildWrapperScript`** + +```ts +// src/sandbox/wrapper-script.test.ts +import { describe, it, expect } from "vitest"; +import { buildWrapperScript } from "./wrapper-script.js"; + +describe("buildWrapperScript", () => { + it("generates a bash script that runs claude and writes sentinel", () => { + const script = buildWrapperScript({ model: "claude-opus-4-6" }); + + expect(script).toContain("#!/bin/bash"); + expect(script).toContain("claude"); + expect(script).toContain("claude-opus-4-6"); + expect(script).toContain("/tmp/agent-done"); + expect(script).toContain("/tmp/agent-stdout.txt"); + expect(script).toContain("/tmp/agent-stderr.txt"); + expect(script).not.toContain("git commit"); // agent commits via stop hook, not wrapper + }); + + it("includes json-schema flag", () => { + const script = buildWrapperScript({ model: "claude-opus-4-6" }); + expect(script).toContain("--json-schema"); + expect(script).toContain("--output-format json"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/sandbox/wrapper-script.test.ts` +Expected: FAIL (module not found) + +- [ ] **Step 3: Implement `buildWrapperScript`** + +```ts +// src/sandbox/wrapper-script.ts +import { AGENT_SCHEMA } from "./agent-runner.js"; + +interface WrapperScriptOptions { + model: string; +} + +/** + * Generates a bash wrapper script that: + * 1. Runs claude --print with the given model (agent commits via stop hook) + * 2. Does cleanup (removes .claude/, requirements.md artifacts) + * 3. Writes stdout/stderr to /tmp/ files + * 4. Touches /tmp/agent-done as sentinel + * + * Designed to run detached inside a Vercel Sandbox. + * The agent is responsible for committing — this script does NOT auto-commit. + */ +export function buildWrapperScript(opts: WrapperScriptOptions): string { + const { model } = opts; + + // Escape single quotes in the schema for safe embedding in bash + const escapedSchema = AGENT_SCHEMA.replace(/'/g, "'\\''"); + + return `#!/bin/bash + +# --- Phase 1: Run Claude Code agent --- +cat /vercel/sandbox/requirements.md | claude \\ + --print \\ + --model "${model}" \\ + --dangerously-skip-permissions \\ + --output-format json \\ + --json-schema '${escapedSchema}' \\ + > /tmp/agent-stdout.txt 2>/tmp/agent-stderr.txt || true + +# --- Phase 2: Cleanup --- +cd /vercel/sandbox + +# Remove repo-level .claude/ artifacts that Claude Code auto-creates. +# git checkout restores any that were already committed. +rm -rf .claude/ requirements.md +git checkout -- .claude/ 2>/dev/null || true +git checkout -- requirements.md 2>/dev/null || true + +# --- Phase 3: Signal completion --- +touch /tmp/agent-done +`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test src/sandbox/wrapper-script.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/sandbox/wrapper-script.ts src/sandbox/wrapper-script.test.ts +git commit -m "feat: add wrapper script generator for detached sandbox agent execution" +``` + +--- + +### Task 3: Create polling and result collection step functions + +These `"use step"` functions reconnect to a sandbox by ID, check for the sentinel file, and collect results. + +**Files:** +- Create: `src/sandbox/poll-agent.ts` +- Create: `src/sandbox/poll-agent.test.ts` + +- [ ] **Step 1: Write failing tests for `checkAgentDone`** + +```ts +// src/sandbox/poll-agent.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockRunCommand = vi.fn(); +const mockReadFileToBuffer = vi.fn(); +const mockStop = vi.fn(); + +vi.mock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn(() => ({ + sandboxId: "sbx-test-123", + status: "running", + runCommand: mockRunCommand, + readFileToBuffer: mockReadFileToBuffer, + stop: mockStop, + })), + }, +})); + +// Must mock the module before importing +vi.mock("./credentials.js", () => ({ + getSandboxCredentials: () => ({}), +})); + +import { checkAgentDone, collectAgentResults, teardownSandbox } from "./poll-agent.js"; + +describe("checkAgentDone", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns false when sentinel file does not exist", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe(false); + }); + + it("returns true when sentinel file exists", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe(true); + }); + + it("returns 'stopped' when sandbox is not running and no sentinel", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockResolvedValueOnce({ + sandboxId: "sbx-test-123", + status: "stopped", + runCommand: mockRunCommand, + }); + // No sentinel check needed — sandbox is dead + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe("stopped"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/sandbox/poll-agent.test.ts` +Expected: FAIL (module not found) + +- [ ] **Step 3: Implement `checkAgentDone`** + +```ts +// src/sandbox/poll-agent.ts +import { getSandboxCredentials } from "./credentials.js"; + +/** + * Reconnects to a sandbox and checks whether the agent has finished. + * Returns: + * - `true` if /tmp/agent-done sentinel exists + * - `false` if sandbox is running but agent not done yet + * - `"stopped"` if sandbox is no longer running (timeout/crash) + */ +export async function checkAgentDone( + sandboxId: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + if (sandbox.status !== "running") { + return "stopped"; + } + + const result = await sandbox.runCommand("test", ["-f", "/tmp/agent-done"]); + return result.exitCode === 0; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test src/sandbox/poll-agent.test.ts` +Expected: PASS + +- [ ] **Step 5: Write failing tests for `collectAgentResults`** + +Add to `src/sandbox/poll-agent.test.ts`: + +```ts +describe("collectAgentResults", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reads stdout, stderr and extracts changed files", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation((...args: unknown[]) => { + const cmdArgs = (args[0] as string) === "bash" ? args[1] : args; + // Respond to different commands based on arguments + return { + exitCode: 0, + stdout: mockStdout, + }; + }); + + // cat /tmp/agent-stdout.txt + mockStdout + .mockResolvedValueOnce(JSON.stringify({ result: "implemented", summary: "Done" })) // stdout + .mockResolvedValueOnce("") // stderr + .mockResolvedValueOnce("abc123") // pre-agent sha + .mockResolvedValueOnce("src/index.ts"); // git diff --name-only + + mockReadFileToBuffer.mockResolvedValue(Buffer.from("console.log('hello')")); + + const result = await collectAgentResults("sbx-test-123"); + + expect(result.output.result).toBe("implemented"); + expect(result.files).toHaveLength(1); + expect(result.files[0].path).toBe("src/index.ts"); + expect(result.files[0].content).toBe("console.log('hello')"); + }); +}); +``` + +- [ ] **Step 6: Implement `collectAgentResults`** + +Add to `src/sandbox/poll-agent.ts`: + +```ts +import { parseAgentOutput } from "./agent-runner.js"; +import type { AgentOutput } from "./agent-runner.js"; + +/** + * Reconnects to the sandbox, reads agent stdout/stderr, extracts changed files, + * and returns the parsed result. + */ +export async function collectAgentResults( + sandboxId: string, +): Promise<{ output: AgentOutput; files: Array<{ path: string; content: string }> }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Read agent output files + const stdoutResult = await sandbox.runCommand("cat", ["/tmp/agent-stdout.txt"]); + const stdout = (await stdoutResult.stdout()).trim(); + + const stderrResult = await sandbox.runCommand("cat", ["/tmp/agent-stderr.txt"]); + const stderr = (await stderrResult.stdout()).trim(); + + const raw = stdout || stderr; + const output = parseAgentOutput(raw); + + // Extract changed files (same logic as SandboxManager.extractChanges) + const baseResult = await sandbox.runCommand("bash", [ + "-c", + "cat /tmp/.pre-agent-sha 2>/dev/null || git rev-list --max-parents=0 HEAD", + ]); + const baseSha = (await baseResult.stdout()).trim(); + + let files: Array<{ path: string; content: string }> = []; + + if (baseSha) { + const diffResult = await sandbox.runCommand("git", [ + "diff", "--name-only", baseSha, "HEAD", + ]); + const diffOutput = (await diffResult.stdout()).trim(); + + if (diffOutput) { + const filePaths = diffOutput + .split("\n") + .filter(Boolean) + .filter((p) => p !== "requirements.md") + .filter((p) => !p.startsWith(".claude/")); + + for (const filePath of filePaths) { + const buf = await sandbox.readFileToBuffer({ + path: filePath, + cwd: "/vercel/sandbox", + }); + if (buf) { + files.push({ path: filePath, content: buf.toString("utf-8") }); + } + } + } + } + + return { output, files }; +} + +/** + * Reconnects to a sandbox and stops it. + */ +export async function teardownSandbox(sandboxId: string): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + try { + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + await sandbox.stop(); + } catch { + // Teardown failures are non-critical (sandbox may have already stopped) + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `pnpm test src/sandbox/poll-agent.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/sandbox/poll-agent.ts src/sandbox/poll-agent.test.ts +git commit -m "feat: add polling step functions for sandbox agent completion" +``` + +--- + +### Task 4: Add wrapper script installation to sandbox provisioning + +Write the wrapper script to the sandbox during `provision()` so it's available for detached execution. + +**Files:** +- Modify: `src/sandbox/manager.ts:151-156` (add wrapper script writing) + +- [ ] **Step 1: Import and write wrapper script in `provision()`** + +In `src/sandbox/manager.ts`, after the line that writes `requirements.md` (line ~152), also write the wrapper script: + +```ts +// In provision(), after writeFiles for requirements.md: + +import { buildWrapperScript } from "./wrapper-script.js"; + +// ... inside provision(): + +// Write wrapper script for detached execution +const wrapperScript = buildWrapperScript({ model: this.config.claudeModel }); +await sandbox.writeFiles([ + { path: "requirements.md", content: Buffer.from(requirementsMd) }, + { path: "/tmp/agent-wrapper.sh", content: Buffer.from(wrapperScript) }, +]); +await sandbox.runCommand("chmod", ["+x", "/tmp/agent-wrapper.sh"]); +``` + +Replace the existing `writeFiles` call (which only writes `requirements.md`) with the combined call above. + +- [ ] **Step 2: Run existing manager tests** + +Run: `pnpm test src/sandbox/manager.test.ts` +Expected: PASS (mock handles writeFiles with any args) + +- [ ] **Step 3: Commit** + +```bash +git add src/sandbox/manager.ts +git commit -m "feat: write agent wrapper script to sandbox during provisioning" +``` + +--- + +### Task 5: Replace `run-agent.ts` with `startAgentDetached` + +Remove the old blocking `runAgent` (including debug streaming code) and replace with a single `startAgentDetached` function. Debug mode (`DEBUG_AGENT` env var) is removed entirely — observability is handled via the WDK workflow dashboard and step logs. + +**Files:** +- Rewrite: `src/sandbox/run-agent.ts` +- Modify: `env.ts` (remove `DEBUG_AGENT`) + +- [ ] **Step 1: Rewrite `src/sandbox/run-agent.ts`** + +Replace the entire file contents with: + +```ts +// src/sandbox/run-agent.ts +import type { Sandbox as SandboxType } from "@vercel/sandbox"; + +type SandboxInstance = Awaited>; + +/** + * Starts the agent wrapper script in detached mode. + * Returns immediately — the agent runs in the background. + * Use `checkAgentDone` / `collectAgentResults` from poll-agent.ts to poll for completion. + */ +export async function startAgentDetached( + sandbox: SandboxInstance, +): Promise { + await sandbox.runCommand({ + cmd: "bash", + args: ["/tmp/agent-wrapper.sh"], + cwd: "/vercel/sandbox", + detached: true, + }); +} +``` + +- [ ] **Step 2: Remove `DEBUG_AGENT` from `env.ts`** + +Remove the `DEBUG_AGENT` field from the env schema in `env.ts`: + +```ts +// REMOVE these lines from env.ts: +DEBUG_AGENT: z + .string() + .transform((v) => v === "true" || v === "1") + .default("false"), +``` + +- [ ] **Step 3: Remove `debug` references from workflow steps** + +In `src/workflows/implementation.ts`, remove `debug: env.DEBUG_AGENT` from the `SandboxManager` constructor call (if present in the new `provisionAndStartAgent` step — it should not be needed since the wrapper script handles everything). + +Same for `src/workflows/review-fix.ts`. + +- [ ] **Step 4: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS (any remaining references to `DEBUG_AGENT` or `runAgent` will surface as type errors — fix them) + +- [ ] **Step 5: Commit** + +```bash +git add src/sandbox/run-agent.ts env.ts src/workflows/implementation.ts src/workflows/review-fix.ts +git commit -m "feat: replace runAgent with startAgentDetached, remove debug mode" +``` + +--- + +### Task 6: Update `implementationWorkflow` to use polling pattern + +Replace the single blocking `runAgentInSandbox` step with the provision → poll → collect pattern. + +**Files:** +- Modify: `src/workflows/implementation.ts` + +- [ ] **Step 1: Replace the `runAgentInSandbox` step** + +Remove the existing `runAgentInSandbox` function (lines 43-71). Replace with two new steps: + +```ts +async function provisionAndStartAgent( + branchName: string, + requirementsMd: string, +): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + const { startAgentDetached } = await import("../sandbox/run-agent.js"); + + const manager = new SandboxManager({ + githubToken: env.GITHUB_TOKEN, + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + }); + + const sandbox = await manager.provision(branchName, requirementsMd); + await startAgentDetached(sandbox); + return sandbox.sandboxId; +} +provisionAndStartAgent.maxRetries = 0; // Don't retry expensive provisioning +``` + +- [ ] **Step 2: Update the workflow orchestration** + +Replace the workflow body (inside the try block, after `assembleImplementationRequirements`) with the poll pattern. Add imports for `sleep` from `"workflow"`: + +```ts +import { sleep } from "workflow"; + +// ... inside implementationWorkflow, in the try block: + + const requirementsMd = await assembleImplementationRequirements(ticket); + + // --- Detached execution with polling --- + const { checkAgentDone, collectAgentResults, teardownSandbox } = + await import("../sandbox/poll-agent.js"); + + const sandboxId = await provisionAndStartAgent(branchName, requirementsMd); + + // Poll until agent finishes — workflow truly suspends between polls. + // Use Date.now() for timeout instead of Promise.race with two sleeps + // (racing two WDK sleep calls is unsafe for deterministic replay). + const POLL_INTERVAL = "30s"; + const TIMEOUT_MS = 35 * 60 * 1000; // 35 min — slightly above JOB_TIMEOUT_MS default (30m) + const startedAt = Date.now(); + let agentDone = false; + + try { + while (!agentDone) { + await sleep(POLL_INTERVAL); + + if (Date.now() - startedAt > TIMEOUT_MS) break; + + const status = await checkAgentDone(sandboxId); + if (status === true) { + agentDone = true; + } else if (status === "stopped") { + // Sandbox died before agent finished + break; + } + // status === false → keep polling + } + + let output: AgentOutput; + let files: Array<{ path: string; content: string }>; + + if (agentDone) { + ({ output, files } = await collectAgentResults(sandboxId)); + } else { + output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; + files = []; + } + + // --- Rest of workflow continues unchanged --- + await pushChanges(branchName, files); + } finally { + await teardownSandbox(sandboxId); + } +``` + +- [ ] **Step 3: Clean up unused imports** + +Remove `runAgent` import and the old `SandboxManager` usage from the removed step. Add `sleep` import: + +```ts +import { sleep } from "workflow"; +import type { AgentOutput } from "../sandbox/agent-runner.js"; +``` + +- [ ] **Step 4: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/workflows/implementation.ts +git commit -m "feat: implement polling-based sandbox suspension in implementation workflow" +``` + +--- + +### Task 7: Update `reviewFixWorkflow` to use polling pattern + +Apply the same polling pattern to the review-fix workflow. + +**Files:** +- Modify: `src/workflows/review-fix.ts` + +- [ ] **Step 1: Replace `runFixingAgentInSandbox` step** + +Remove the existing `runFixingAgentInSandbox` function (lines 66-100). Replace with: + +```ts +async function provisionAndStartFixingAgent( + branchName: string, + requirementsMd: string, + mergeBase: string, +): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + const { startAgentDetached } = await import("../sandbox/run-agent.js"); + + const manager = new SandboxManager({ + githubToken: env.GITHUB_TOKEN, + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + }); + + const sandbox = await manager.provision(branchName, requirementsMd, mergeBase); + await startAgentDetached(sandbox); + return sandbox.sandboxId; +} +provisionAndStartFixingAgent.maxRetries = 0; +``` + +- [ ] **Step 2: Update workflow orchestration** + +Replace the workflow body (after `assembleReviewFixRequirements`) with the poll pattern — same as Task 6 Step 2 but calling `provisionAndStartFixingAgent(branchName, requirementsMd, env.GITHUB_BASE_BRANCH)` instead: + +```ts +import { sleep } from "workflow"; + +// ... inside reviewFixWorkflow, in the try block, after assembling requirements: + + const { checkAgentDone, collectAgentResults, teardownSandbox } = + await import("../sandbox/poll-agent.js"); + + const sandboxId = await provisionAndStartFixingAgent( + branchName, + requirementsMd, + env.GITHUB_BASE_BRANCH, + ); + + // Same Date.now() elapsed-time pattern as implementation workflow + // (racing two WDK sleep calls is unsafe for deterministic replay). + const POLL_INTERVAL = "30s"; + const TIMEOUT_MS = 35 * 60 * 1000; // 35 min + const startedAt = Date.now(); + let agentDone = false; + + try { + while (!agentDone) { + await sleep(POLL_INTERVAL); + + if (Date.now() - startedAt > TIMEOUT_MS) break; + + const status = await checkAgentDone(sandboxId); + if (status === true) { + agentDone = true; + } else if (status === "stopped") { + break; + } + } + + let output: AgentOutput; + let files: Array<{ path: string; content: string }>; + + if (agentDone) { + ({ output, files } = await collectAgentResults(sandboxId)); + } else { + output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; + files = []; + } + + await pushChanges(branchName, files, baseSha); + } finally { + await teardownSandbox(sandboxId); + } +``` + +- [ ] **Step 3: Clean up imports** + +Add `sleep` import, add `AgentOutput` type import, remove unused imports. + +- [ ] **Step 4: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/workflows/review-fix.ts +git commit -m "feat: implement polling-based sandbox suspension in review-fix workflow" +``` + +--- + +### Task 8: Run full test suite and fix issues + +- [ ] **Step 1: Run all unit tests** + +Run: `pnpm test` +Expected: PASS — all existing tests should still pass. The `manager.test.ts` mock includes `writeFiles` which accepts any args, and we only added to the existing `provision()` flow. + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS + +- [ ] **Step 3: Fix any failures** + +Address any test or type failures found. Common issues: +- `manager.test.ts` may need an extra `mockRunCommand` call for the `chmod` on the wrapper script +- Import paths may need `.js` extension for ESM + +- [ ] **Step 4: Final commit** + +```bash +git add -A +git commit -m "fix: resolve test/type issues from sandbox polling refactor" +``` + +--- + +## Summary of Changes + +| Before | After | +|--------|-------| +| Single blocking step runs agent for 10-30 min | Detached start → workflow suspends → polls every 30s | +| Workflow consumes resources entire time | Workflow at zero resources during agent execution | +| No timeout handling | `Date.now()` elapsed-time check (35 min) per poll iteration | +| Sandbox teardown in same step | Separate teardown step in `finally` block (always runs) | +| Debug mode: live streaming via `getWritable` | Debug mode: removed entirely | +| Wrapper auto-commits uncommitted changes | Agent commits via stop hook with descriptive message | + +## Not In Scope (Future Work) + +- **Hook-based callback**: If 30s polling latency is unacceptable, switch to `createHook`/`resumeHook` with a callback route. +- **Progress streaming**: The wrapper script could write progress to a file that the poll step reads and streams via `getWritable()`. diff --git a/env.ts b/env.ts index 7ffe8bd..9255357 100644 --- a/env.ts +++ b/env.ts @@ -51,13 +51,6 @@ export const env = createEnv({ // Redis (run registry) AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), - - - // Debug - DEBUG_AGENT: z - .string() - .transform((v) => v === "true" || v === "1") - .default("false"), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/src/adapters/run-registry/upstash.test.ts b/src/adapters/run-registry/upstash.test.ts index 549e29d..7e4184d 100644 --- a/src/adapters/run-registry/upstash.test.ts +++ b/src/adapters/run-registry/upstash.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { UpstashRunRegistry } from "./upstash.js"; -const HASH_KEY = "blazebot:active-runs"; +const HASH_KEY = `blazebot:active-runs:${process.env.VERCEL_ENV ?? "development"}`; const mockRedis = { hsetnx: vi.fn(), @@ -9,6 +9,7 @@ const mockRedis = { hget: vi.fn(), hdel: vi.fn(), hgetall: vi.fn(), + persist: vi.fn(), }; vi.mock("@upstash/redis", () => ({ diff --git a/src/adapters/run-registry/upstash.ts b/src/adapters/run-registry/upstash.ts index 9ecae9e..2800f15 100644 --- a/src/adapters/run-registry/upstash.ts +++ b/src/adapters/run-registry/upstash.ts @@ -1,7 +1,8 @@ import { Redis } from "@upstash/redis"; import type { RunRegistryAdapter } from "./types.js"; -const HASH_KEY = "blazebot:active-runs"; +const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; +const HASH_KEY = `blazebot:active-runs:${ENV_PREFIX}`; export class UpstashRunRegistry implements RunRegistryAdapter { private redis: Redis; @@ -17,6 +18,8 @@ export class UpstashRunRegistry implements RunRegistryAdapter { async register(ticketKey: string, runId: string): Promise { await this.redis.hset(HASH_KEY, { [ticketKey]: runId }); + // Ensure the hash has no expiry — defend against external TTL being set + await this.redis.persist(HASH_KEY); } async getRunId(ticketKey: string): Promise { diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index bf3f25e..aef79d9 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -120,7 +120,7 @@ describe("reconcileRuns", () => { expect(mockCancelRun).toHaveBeenCalledWith("PROJ-1", "run_stale", registry); }); - it("cleans unreachable runs (getRun throws)", async () => { + it("does not unregister on a single getRun failure (strike 1 of 3)", async () => { const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: "run_ghost" }, ]); @@ -129,9 +129,56 @@ describe("reconcileRuns", () => { }); const { reconcileRuns } = await import("./reconcile.js"); + // First failure — should NOT unregister (strike 1) + const result = await reconcileRuns(new Set(["PROJ-1"]), registry); + + expect(result).toEqual({ cancelled: 0, cleaned: 0 }); + expect(registry.unregister).not.toHaveBeenCalled(); + }); + + it("unregisters after 3 consecutive getRun failures", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_ghost" }, + ]); + mockGetRun.mockReturnValue({ + get status() { return Promise.reject(new Error("not found")); }, + }); + const { reconcileRuns } = await import("./reconcile.js"); + + // Strike 2 (strike 1 was in the previous test — same module instance) + await reconcileRuns(new Set(["PROJ-1"]), registry); + expect(registry.unregister).not.toHaveBeenCalled(); + + // Strike 3 — should unregister now const result = await reconcileRuns(new Set(["PROJ-1"]), registry); expect(result).toEqual({ cancelled: 0, cleaned: 1 }); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); }); + + it("resets strike counter on successful getRun", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_flaky" }, + ]); + const { reconcileRuns } = await import("./reconcile.js"); + + // One failure — strike 1 + mockGetRun.mockReturnValue({ + get status() { return Promise.reject(new Error("transient")); }, + }); + await reconcileRuns(new Set(["PROJ-1"]), registry); + expect(registry.unregister).not.toHaveBeenCalled(); + + // Success — resets counter + mockGetRun.mockReturnValue({ status: Promise.resolve("running") }); + await reconcileRuns(new Set(["PROJ-1"]), registry); + expect(registry.unregister).not.toHaveBeenCalled(); + + // Another failure — strike 1 again (not 2) + mockGetRun.mockReturnValue({ + get status() { return Promise.reject(new Error("transient")); }, + }); + await reconcileRuns(new Set(["PROJ-1"]), registry); + expect(registry.unregister).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index 8a74a62..fdbbd39 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -7,6 +7,15 @@ import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); const STALE_CLAIM_MS = 5 * 60 * 1000; +/** + * Track consecutive getRun failures per ticket. + * Only unregister after UNREACHABLE_STRIKES_LIMIT consecutive failures + * to avoid nuking dedup entries on transient WDK API errors + * (especially during workflow sleep/suspend states). + */ +const unreachableStrikes = new Map(); +const UNREACHABLE_STRIKES_LIMIT = 3; + export async function reconcileRuns( aiColumnTickets: Set, runRegistry: RunRegistryAdapter, @@ -76,12 +85,28 @@ async function cleanFinishedRun( const run = getRun(runId); const status = await run.status; + // Success — reset strike counter + unreachableStrikes.delete(ticketKey); + if (!TERMINAL_STATUSES.has(status)) return 0; await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId, status }, "reconcile_cleaned_finished_run"); return 1; } catch { + const strikes = (unreachableStrikes.get(ticketKey) ?? 0) + 1; + unreachableStrikes.set(ticketKey, strikes); + + if (strikes < UNREACHABLE_STRIKES_LIMIT) { + logger.warn( + { ticketKey, runId, strikes, limit: UNREACHABLE_STRIKES_LIMIT }, + "reconcile_unreachable_strike", + ); + return 0; + } + + // Exceeded strike limit — genuinely gone + unreachableStrikes.delete(ticketKey); await runRegistry.unregister(ticketKey).catch(() => {}); logger.warn({ ticketKey, runId }, "reconcile_cleaned_unreachable_run"); return 1; diff --git a/src/sandbox/agent-runner.ts b/src/sandbox/agent-runner.ts index e832083..833b417 100644 --- a/src/sandbox/agent-runner.ts +++ b/src/sandbox/agent-runner.ts @@ -103,60 +103,3 @@ export function parseAgentOutput(raw: string): AgentOutput { error: `Agent output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, }; } - -/** - * Format a stream-json event into a human-readable log line. - * Returns null for events that aren't worth logging. - */ -export function formatStreamEvent(line: string): string | null { - try { - const e = JSON.parse(line); - - if (e.type === "system" && e.subtype === "init") { - return `[init] session=${e.session_id} model=${e.model}`; - } - if (e.type === "assistant" && e.message?.content) { - const parts: string[] = []; - for (const block of e.message.content) { - if (block.type === "text" && block.text) { - parts.push(block.text); - } - if (block.type === "tool_use") { - parts.push(`[tool] ${block.name}(${JSON.stringify(block.input).slice(0, 200)})`); - } - } - return parts.join("\n") || null; - } - if (e.type === "tool_result") { - const content = typeof e.content === "string" - ? e.content.slice(0, 300) - : JSON.stringify(e.content).slice(0, 300); - return `[result] ${e.name ?? "tool"}: ${content}`; - } - if (e.type === "result") { - return `[done] ${e.subtype} turns=${e.num_turns} cost=$${e.total_cost_usd?.toFixed(2) ?? "?"}`; - } - return null; - } catch { - return null; - } -} - -export function buildAgentCommand(model: string, debug = false): { - cmd: string; - args: string[]; -} { - const flags = [ - "--print", - `--model "${model}"`, - "--dangerously-skip-permissions", - ...(debug - ? ["--output-format stream-json", "--verbose", `--json-schema '${AGENT_SCHEMA}'`] - : ["--output-format json", `--json-schema '${AGENT_SCHEMA}'`]), - ].join(" "); - - return { - cmd: "bash", - args: ["-c", `cat /vercel/sandbox/requirements.md | claude ${flags}`], - }; -} diff --git a/src/sandbox/credentials.ts b/src/sandbox/credentials.ts new file mode 100644 index 0000000..bdfb5a4 --- /dev/null +++ b/src/sandbox/credentials.ts @@ -0,0 +1,20 @@ +type Credentials = { + token: string; + teamId: string; + projectId: string; +}; + +/** + * Returns explicit Sandbox credentials when all three env vars are set (local dev). + * On Vercel, returns empty object — the SDK authenticates via OIDC automatically. + */ +export function getSandboxCredentials(): Partial { + const token = process.env.VERCEL_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + const projectId = process.env.VERCEL_PROJECT_ID; + + if (token && teamId && projectId) { + return { token, teamId, projectId }; + } + return {}; +} diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index fa8dbe2..0ad0e5f 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -63,58 +63,4 @@ describe("SandboxManager", () => { expect(sandbox.sandboxId).toBe("sbx-test-123"); }); - it("runs end hook and detects clean state", async () => { - mockStdout.mockResolvedValueOnce(""); - - const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - const sandbox = await manager.provision("feat/test", "# Req"); - const result = await manager.runEndHook(sandbox); - - expect(result).toBe("clean"); - }); - - it("commits uncommitted changes in end hook", async () => { - const endHookStdout = vi.fn() - .mockResolvedValueOnce(" M src/index.ts"); // git status --porcelain - mockRunCommand - // provision calls (git config + pre-agent-sha + npm install + stop hook + 3 skill installs) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) - // runEndHook calls - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) // rm -rf .claude/ requirements.md - .mockResolvedValueOnce({ exitCode: 0, stdout: endHookStdout }) // git status - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }) // git add - .mockResolvedValueOnce({ exitCode: 0, stdout: vi.fn().mockResolvedValue("") }); // git commit - - const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - const sandbox = await manager.provision("feat/test", "# Req"); - const result = await manager.runEndHook(sandbox); - - expect(result).toBe("committed"); - }); }); diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 7be611c..dfbe49b 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,4 +1,6 @@ import type { Sandbox as SandboxType } from "@vercel/sandbox"; +import { getSandboxCredentials } from "./credentials.js"; +import { buildWrapperScript } from "./wrapper-script.js"; /** * Skills installed globally in the sandbox (~/.claude/skills/). @@ -20,15 +22,10 @@ export interface SandboxConfig { commitAuthor: string; commitEmail: string; jobTimeoutMs: number; - vercelToken?: string; - vercelTeamId?: string; - vercelProjectId?: string; } type SandboxInstance = Awaited>; -export type EndHookResult = "clean" | "committed" | "error"; - export class SandboxManager { constructor(private config: SandboxConfig) {} @@ -40,23 +37,12 @@ export class SandboxManager { ): Promise { const { Sandbox } = await import("@vercel/sandbox"); - // Pass explicit credentials only when all three are provided (local dev). - // On Vercel, omit them entirely so the SDK uses OIDC auto-detection. if (!this.config.claudeCodeOauthToken && !this.config.anthropicApiKey) { throw new Error("Either anthropicApiKey or claudeCodeOauthToken must be provided"); } - const hasExplicitCredentials = - this.config.vercelToken && this.config.vercelTeamId && this.config.vercelProjectId; - const sandbox = await Sandbox.create({ - ...(hasExplicitCredentials - ? { - token: this.config.vercelToken, - teamId: this.config.vercelTeamId, - projectId: this.config.vercelProjectId, - } - : {}), + ...getSandboxCredentials(), source: { type: "git", url: `https://github.com/${this.config.owner}/${this.config.repo}.git`, @@ -104,7 +90,7 @@ export class SandboxManager { } } - // Record the pre-agent HEAD so extractChanges can diff only agent work. + // Record the pre-agent HEAD so the poll step (collectAgentResults) can diff only agent work. // Must happen after clone + optional merge, before the agent touches anything. await sandbox.runCommand("bash", [ "-c", @@ -148,10 +134,13 @@ export class SandboxManager { // Install skills globally (outside the client repo) await this.installGlobalSkills(sandbox); - // Write requirements.md + // Write requirements.md and wrapper script for detached execution + const wrapperScript = buildWrapperScript({ model: this.config.claudeModel }); await sandbox.writeFiles([ { path: "requirements.md", content: Buffer.from(requirementsMd) }, + { path: "/tmp/agent-wrapper.sh", content: Buffer.from(wrapperScript) }, ]); + await sandbox.runCommand("chmod", ["+x", "/tmp/agent-wrapper.sh"]); return sandbox; } @@ -168,79 +157,6 @@ export class SandboxManager { } } - async runEndHook(sandbox: SandboxInstance): Promise { - try { - // Remove repo-level .claude/ artifacts that Claude Code auto-creates at runtime. - // rm -rf removes untracked files; git checkout restores any that were already committed - // so their deletion doesn't appear as dirty state. - await sandbox.runCommand("bash", [ - "-c", - "cd /vercel/sandbox; rm -rf .claude/ requirements.md; git checkout -- .claude/ 2>/dev/null; git checkout -- requirements.md 2>/dev/null; true", - ]); - - const statusResult = await sandbox.runCommand("git", [ - "status", - "--porcelain", - ]); - const status = (await statusResult.stdout()).trim(); - - if (!status) return "clean"; - - // Uncommitted changes exist — force commit - await sandbox.runCommand("git", ["add", "-A"]); - await sandbox.runCommand("git", [ - "commit", - "-m", - "wip: auto-commit uncommitted changes before sandbox teardown", - ]); - - return "committed"; - } catch { - return "error"; - } - } - - async extractChanges( - sandbox: SandboxInstance, - ): Promise> { - // Diff against the pre-agent snapshot saved during provision(). - // This captures exactly the agent's work, regardless of whether the clone - // was unshallowed (mergeBase) or remains shallow. - const baseResult = await sandbox.runCommand("bash", [ - "-c", - "cat /tmp/.pre-agent-sha 2>/dev/null || git rev-list --max-parents=0 HEAD", - ]); - const baseSha = (await baseResult.stdout()).trim(); - if (!baseSha) return []; - - const diffResult = await sandbox.runCommand("git", [ - "diff", - "--name-only", - baseSha, - "HEAD", - ]); - const diffOutput = (await diffResult.stdout()).trim(); - if (!diffOutput) return []; - - const filePaths = diffOutput - .split("\n") - .filter(Boolean) - .filter((p) => p !== "requirements.md") - .filter((p) => !p.startsWith(".claude/")); - const files: Array<{ path: string; content: string }> = []; - - for (const filePath of filePaths) { - const buf = await sandbox.readFileToBuffer({ - path: filePath, - cwd: "/vercel/sandbox", - }); - if (buf) { - files.push({ path: filePath, content: buf.toString("utf-8") }); - } - } - return files; - } - async teardown(sandbox: SandboxInstance): Promise { try { await sandbox.stop(); diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts new file mode 100644 index 0000000..944f3ea --- /dev/null +++ b/src/sandbox/poll-agent.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockRunCommand = vi.fn(); +const mockReadFileToBuffer = vi.fn(); +const mockStop = vi.fn(); + +vi.mock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn(() => ({ + sandboxId: "sbx-test-123", + status: "running", + runCommand: mockRunCommand, + readFileToBuffer: mockReadFileToBuffer, + stop: mockStop, + })), + }, +})); + +// Must mock the module before importing +vi.mock("./credentials.js", () => ({ + getSandboxCredentials: () => ({}), +})); + +import { checkAgentDone, collectAgentResults, teardownSandbox } from "./poll-agent.js"; + +describe("checkAgentDone", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns false when sentinel file does not exist", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe(false); + }); + + it("returns true when sentinel file exists", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe(true); + }); + + it("returns 'stopped' when sandbox is not running", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockResolvedValueOnce({ + sandboxId: "sbx-test-123", + status: "stopped", + runCommand: mockRunCommand, + }); + + const result = await checkAgentDone("sbx-test-123"); + expect(result).toBe("stopped"); + }); +}); + +describe("collectAgentResults", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns failure when sandbox is unreachable", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockRejectedValueOnce(new Error("gone")); + + const result = await collectAgentResults("sbx-test-123"); + + expect(result.output.result).toBe("failed"); + expect(result.output.error).toContain("unreachable"); + expect(result.files).toHaveLength(0); + }); + + it("reads stdout, stderr and extracts changed files", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation(() => ({ + exitCode: 0, + stdout: mockStdout, + })); + + mockStdout + .mockResolvedValueOnce(JSON.stringify({ result: "implemented", summary: "Done" })) // stdout + .mockResolvedValueOnce("") // stderr + .mockResolvedValueOnce("abc123") // pre-agent sha + .mockResolvedValueOnce("src/index.ts"); // git diff --name-only + + mockReadFileToBuffer.mockResolvedValue(Buffer.from("console.log('hello')")); + + const result = await collectAgentResults("sbx-test-123"); + + expect(result.output.result).toBe("implemented"); + expect(result.files).toHaveLength(1); + expect(result.files[0].path).toBe("src/index.ts"); + expect(result.files[0].content).toBe("console.log('hello')"); + }); +}); + +describe("teardownSandbox", () => { + beforeEach(() => vi.clearAllMocks()); + + it("stops the sandbox", async () => { + await teardownSandbox("sbx-test-123"); + expect(mockStop).toHaveBeenCalled(); + }); + + it("does not throw on error", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockRejectedValueOnce(new Error("gone")); + + await expect(teardownSandbox("sbx-test-123")).resolves.not.toThrow(); + }); +}); diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts new file mode 100644 index 0000000..941590a --- /dev/null +++ b/src/sandbox/poll-agent.ts @@ -0,0 +1,112 @@ +import { getSandboxCredentials } from "./credentials.js"; +import { parseAgentOutput } from "./agent-runner.js"; +import type { AgentOutput } from "./agent-runner.js"; + +/** + * Reconnects to a sandbox and checks whether the agent has finished. + * Returns: + * - `true` if /tmp/agent-done sentinel exists + * - `false` if sandbox is running but agent not done yet + * - `"stopped"` if sandbox is no longer running (timeout/crash) + */ +export async function checkAgentDone( + sandboxId: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + try { + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + if (sandbox.status !== "running") { + return "stopped"; + } + + const result = await sandbox.runCommand("test", ["-f", "/tmp/agent-done"]); + return result.exitCode === 0; + } catch { + // Sandbox unreachable (network error, GC'd, etc.) — treat as stopped + return "stopped"; + } +} + +/** + * Reconnects to the sandbox, reads agent stdout/stderr, extracts changed files, + * and returns the parsed result. + */ +export async function collectAgentResults( + sandboxId: string, +): Promise<{ output: AgentOutput; files: Array<{ path: string; content: string }> }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + + let sandbox; + try { + sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + } catch { + // Sandbox unreachable between final poll and collection — return a clear failure + return { + output: { result: "failed", error: "Sandbox became unreachable before results could be collected" }, + files: [], + }; + } + + // Read agent output files + const stdoutResult = await sandbox.runCommand("cat", ["/tmp/agent-stdout.txt"]); + const stdout = (await stdoutResult.stdout()).trim(); + + const stderrResult = await sandbox.runCommand("cat", ["/tmp/agent-stderr.txt"]); + const stderr = (await stderrResult.stdout()).trim(); + + const raw = stdout || stderr; + const output = parseAgentOutput(raw); + + // Extract changed files + const baseResult = await sandbox.runCommand("bash", [ + "-c", + "cat /tmp/.pre-agent-sha 2>/dev/null || git rev-list --max-parents=0 HEAD", + ]); + const baseSha = (await baseResult.stdout()).trim(); + + let files: Array<{ path: string; content: string }> = []; + + if (baseSha) { + const diffResult = await sandbox.runCommand("git", [ + "diff", "--name-only", baseSha, "HEAD", + ]); + const diffOutput = (await diffResult.stdout()).trim(); + + if (diffOutput) { + const filePaths = diffOutput + .split("\n") + .filter(Boolean) + .filter((p) => p !== "requirements.md") + .filter((p) => !p.startsWith(".claude/")); + + for (const filePath of filePaths) { + const buf = await sandbox.readFileToBuffer({ + path: filePath, + cwd: "/vercel/sandbox", + }); + if (buf) { + files.push({ path: filePath, content: buf.toString("utf-8") }); + } + } + } + } + + return { output, files }; +} + +/** + * Reconnects to a sandbox and stops it. + */ +export async function teardownSandbox(sandboxId: string): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + try { + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + await sandbox.stop(); + } catch { + // Teardown failures are non-critical (sandbox may have already stopped) + } +} diff --git a/src/sandbox/run-agent.ts b/src/sandbox/run-agent.ts index a426b11..9ef0cb4 100644 --- a/src/sandbox/run-agent.ts +++ b/src/sandbox/run-agent.ts @@ -1,80 +1,19 @@ import type { Sandbox as SandboxType } from "@vercel/sandbox"; -import { getWritable } from "workflow"; -import { buildAgentCommand, parseAgentOutput, formatStreamEvent } from "./agent-runner.js"; -import type { AgentOutput } from "./agent-runner.js"; -import { SandboxManager } from "./manager.js"; type SandboxInstance = Awaited>; -interface RunAgentOptions { - sandbox: SandboxInstance; - manager: SandboxManager; - model: string; - debug: boolean; -} - -export async function runAgent( - opts: RunAgentOptions, -): Promise<{ output: AgentOutput; files: Array<{ path: string; content: string }> }> { - const { sandbox, manager, model, debug } = opts; - - try { - const { cmd, args } = buildAgentCommand(model, debug); - - let stdout: string; - let stderr: string; - - if (debug) { - const command = await sandbox.runCommand({ cmd, args, cwd: "/vercel/sandbox", detached: true }); - - const writable = getWritable(); - const writer = writable.getWriter(); - stdout = ""; - stderr = ""; - let lineBuf = ""; - try { - await writer.write("[debug] Agent started\n"); - for await (const log of command.logs()) { - if (log.stream === "stdout") { - stdout += log.data; - lineBuf += log.data; - const lines = lineBuf.split("\n"); - lineBuf = lines.pop() ?? ""; - for (const line of lines.filter(Boolean)) { - const formatted = formatStreamEvent(line); - if (formatted) await writer.write(formatted + "\n"); - } - } else { - stderr += log.data; - } - } - // Flush remaining buffer - if (lineBuf.trim()) { - const formatted = formatStreamEvent(lineBuf); - if (formatted) await writer.write(formatted + "\n"); - } - await writer.write("[debug] Agent finished\n"); - } finally { - writer.releaseLock(); - } - await command.wait(); - } else { - const result = await sandbox.runCommand({ cmd, args, cwd: "/vercel/sandbox" }); - stdout = await result.stdout(); - stderr = await result.stderr(); - } - - await manager.runEndHook(sandbox); - const files = await manager.extractChanges(sandbox); - - const raw = stdout.trim() || stderr.trim(); - const output = parseAgentOutput(raw); - return { output, files }; - } catch (err) { - await manager.runEndHook(sandbox).catch(() => {}); - const files = await manager.extractChanges(sandbox).catch(() => []); - throw Object.assign(err as Error, { files }); - } finally { - await manager.teardown(sandbox); - } +/** + * Starts the agent wrapper script in detached mode. + * Returns immediately — the agent runs in the background. + * Use `checkAgentDone` / `collectAgentResults` from poll-agent.ts to poll for completion. + */ +export async function startAgentDetached( + sandbox: SandboxInstance, +): Promise { + await sandbox.runCommand({ + cmd: "bash", + args: ["/tmp/agent-wrapper.sh"], + cwd: "/vercel/sandbox", + detached: true, + }); } diff --git a/src/sandbox/wrapper-script.test.ts b/src/sandbox/wrapper-script.test.ts new file mode 100644 index 0000000..40af866 --- /dev/null +++ b/src/sandbox/wrapper-script.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { buildWrapperScript } from "./wrapper-script.js"; + +describe("buildWrapperScript", () => { + it("generates a bash script that runs claude and writes sentinel", () => { + const script = buildWrapperScript({ model: "claude-opus-4-6" }); + + expect(script).toContain("#!/bin/bash"); + expect(script).toContain("claude"); + expect(script).toContain("claude-opus-4-6"); + expect(script).toContain("/tmp/agent-done"); + expect(script).toContain("/tmp/agent-stdout.txt"); + expect(script).toContain("/tmp/agent-stderr.txt"); + expect(script).not.toContain("git commit"); // agent commits via stop hook, not wrapper + }); + + it("includes json-schema flag", () => { + const script = buildWrapperScript({ model: "claude-opus-4-6" }); + expect(script).toContain("--json-schema"); + expect(script).toContain("--output-format json"); + }); +}); diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts new file mode 100644 index 0000000..6e424da --- /dev/null +++ b/src/sandbox/wrapper-script.ts @@ -0,0 +1,46 @@ +import { AGENT_SCHEMA } from "./agent-runner.js"; + +interface WrapperScriptOptions { + model: string; +} + +/** + * Generates a bash wrapper script that: + * 1. Runs claude --print with the given model (agent commits via stop hook) + * 2. Does cleanup (removes .claude/, requirements.md artifacts) + * 3. Writes stdout/stderr to /tmp/ files + * 4. Touches /tmp/agent-done as sentinel + * + * Designed to run detached inside a Vercel Sandbox. + * The agent is responsible for committing — this script does NOT auto-commit. + */ +export function buildWrapperScript(opts: WrapperScriptOptions): string { + const { model } = opts; + + // Escape single quotes in the schema for safe embedding in bash + const escapedSchema = AGENT_SCHEMA.replace(/'/g, "'\\''"); + + return `#!/bin/bash + +# --- Phase 1: Run Claude Code agent --- +cat /vercel/sandbox/requirements.md | claude \\ + --print \\ + --model '${model}' \\ + --dangerously-skip-permissions \\ + --output-format json \\ + --json-schema '${escapedSchema}' \\ + > /tmp/agent-stdout.txt 2>/tmp/agent-stderr.txt; echo $? > /tmp/agent-exit-code || true + +# --- Phase 2: Cleanup --- +cd /vercel/sandbox + +# Remove repo-level .claude/ artifacts that Claude Code auto-creates. +# git checkout restores any that were already committed. +rm -rf .claude/ requirements.md +git checkout -- .claude/ 2>/dev/null || true +git checkout -- requirements.md 2>/dev/null || true + +# --- Phase 3: Signal completion --- +touch /tmp/agent-done +`; +} diff --git a/src/workflows/implementation.ts b/src/workflows/implementation.ts index 29b641c..1edf47d 100644 --- a/src/workflows/implementation.ts +++ b/src/workflows/implementation.ts @@ -1,3 +1,4 @@ +import { sleep } from "workflow"; import type { AgentOutput } from "../sandbox/agent-runner.js"; import type { TicketContent } from "../adapters/issue-tracker/types.js"; @@ -40,14 +41,14 @@ async function assembleImplementationRequirements(ticket: TicketContent) { }); } -async function runAgentInSandbox( +async function provisionAndStartAgent( branchName: string, requirementsMd: string, -): Promise<{ output: AgentOutput; files: Array<{ path: string; content: string }> }> { +): Promise { "use step"; const { env } = await import("../../env.js"); const { SandboxManager } = await import("../sandbox/manager.js"); - const { runAgent } = await import("../sandbox/run-agent.js"); + const { startAgentDetached } = await import("../sandbox/run-agent.js"); const manager = new SandboxManager({ githubToken: env.GITHUB_TOKEN, @@ -59,16 +60,13 @@ async function runAgentInSandbox( commitAuthor: env.COMMIT_AUTHOR, commitEmail: env.COMMIT_EMAIL, jobTimeoutMs: env.JOB_TIMEOUT_MS, - vercelToken: env.VERCEL_TOKEN, - vercelTeamId: env.VERCEL_TEAM_ID, - vercelProjectId: env.VERCEL_PROJECT_ID, }); - // No mergeBase needed — the branch was just created from GITHUB_BASE_BRANCH, - // so it's already at the tip. Only review-fix passes mergeBase to handle drift. const sandbox = await manager.provision(branchName, requirementsMd); - return runAgent({ sandbox, manager, model: env.CLAUDE_MODEL, debug: env.DEBUG_AGENT }); + await startAgentDetached(sandbox); + return sandbox.sandboxId; } +provisionAndStartAgent.maxRetries = 0; async function pushChanges( branchName: string, @@ -144,38 +142,84 @@ export async function implementationWorkflow(ticketId: string) { await createFeatureBranch(branchName, env.GITHUB_BASE_BRANCH); const requirementsMd = await assembleImplementationRequirements(ticket); - const { output, files } = await runAgentInSandbox(branchName, requirementsMd); - await pushChanges(branchName, files); - - if (output.result === "implemented") { - await createPullRequest(branchName, ticket.title, output.summary ?? ""); - await moveTicket(ticketId, env.COLUMN_AI_REVIEW); - await notifySlack(`Task ${ticket.identifier} PR ready for review`); - await unregisterRun(ticket.identifier); - return; - } - - if (output.result === "clarification_needed") { - await postClarificationAndMoveBack( - ticketId, - output.questions ?? [], - ticket.identifier, - env.COLUMN_BACKLOG, - ); - await notifySlack(`Task ${ticket.identifier} needs clarification`); + // --- Detached execution with polling --- + const { checkAgentDone, collectAgentResults, teardownSandbox } = + await import("../sandbox/poll-agent.js"); + + const sandboxId = await provisionAndStartAgent(branchName, requirementsMd); + + // Poll until agent finishes — workflow truly suspends between polls. + // Use an iteration counter (not Date.now()) for deterministic WDK replay. + const POLL_INTERVAL = "30s"; + const MAX_POLLS = Math.ceil((35 * 60) / 30); // ~70 iterations ≈ 35 min + let pollCount = 0; + let agentDone = false; + + try { + while (!agentDone) { + await sleep(POLL_INTERVAL); + pollCount++; + + if (pollCount >= MAX_POLLS) break; + + const status = await checkAgentDone(sandboxId); + if (status === true) { + agentDone = true; + } else if (status === "stopped") { + break; + } + } + + let output: AgentOutput; + let files: Array<{ path: string; content: string }>; + + if (agentDone) { + ({ output, files } = await collectAgentResults(sandboxId)); + } else { + output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; + files = []; + } + + await pushChanges(branchName, files); + + if (output.result === "implemented") { + await createPullRequest(branchName, ticket.title, output.summary ?? ""); + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + await notifySlack(`Task ${ticket.identifier} PR ready for review`); + await unregisterRun(ticket.identifier); + return; + } + + if (output.result === "clarification_needed") { + await postClarificationAndMoveBack( + ticketId, + output.questions ?? [], + ticket.identifier, + env.COLUMN_BACKLOG, + ); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: ${output.error ?? "unknown error"}`); await unregisterRun(ticket.identifier); - return; + } finally { + await teardownSandbox(sandboxId); } - - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: ${output.error ?? "unknown error"}`); - await unregisterRun(ticket.identifier); } catch (err) { console.error(`Workflow failed for ${ticket.identifier}:`, err); - await moveTicket(ticketId, env.COLUMN_BACKLOG).catch(() => {}); + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - await unregisterRun(ticket.identifier).catch(() => {}); + // Only unregister if the ticket was moved out of AI column. + // If moveTicket failed, leave the Redis entry so the cron doesn't + // dispatch a duplicate — reconcile will clean it up once the ticket + // is manually moved or the run becomes terminal. + if (moved) { + await unregisterRun(ticket.identifier).catch(() => {}); + } throw err; } } diff --git a/src/workflows/review-fix.ts b/src/workflows/review-fix.ts index ce41691..b03e251 100644 --- a/src/workflows/review-fix.ts +++ b/src/workflows/review-fix.ts @@ -1,4 +1,5 @@ import { FatalError } from "workflow"; +import { sleep } from "workflow"; import type { AgentOutput } from "../sandbox/agent-runner.js"; import type { TicketContent } from "../adapters/issue-tracker/types.js"; import type { PRComment } from "../adapters/vcs/types.js"; @@ -27,9 +28,6 @@ async function fetchPRContext(branchName: string, baseBranch: string) { const comments = await vcs.getPRComments(pr.id); const hasConflicts = await vcs.getPRConflictStatus(pr.id); - // Resolve the base branch SHA so we can create a merge commit when pushing - // conflict resolutions (a merge commit with two parents tells GitHub the - // histories are reconciled and clears the "has conflicts" status). let baseSha: string | undefined; if (hasConflicts) { baseSha = await vcs.getBranchSha(baseBranch); @@ -63,17 +61,15 @@ async function assembleReviewFixRequirements( }); } -async function runFixingAgentInSandbox( +async function provisionAndStartFixingAgent( branchName: string, requirementsMd: string, -): Promise<{ - output: AgentOutput; - files: Array<{ path: string; content: string }>; -}> { + mergeBase: string, +): Promise { "use step"; const { env } = await import("../../env.js"); const { SandboxManager } = await import("../sandbox/manager.js"); - const { runAgent } = await import("../sandbox/run-agent.js"); + const { startAgentDetached } = await import("../sandbox/run-agent.js"); const manager = new SandboxManager({ githubToken: env.GITHUB_TOKEN, @@ -85,19 +81,13 @@ async function runFixingAgentInSandbox( commitAuthor: env.COMMIT_AUTHOR, commitEmail: env.COMMIT_EMAIL, jobTimeoutMs: env.JOB_TIMEOUT_MS, - vercelToken: env.VERCEL_TOKEN, - vercelTeamId: env.VERCEL_TEAM_ID, - vercelProjectId: env.VERCEL_PROJECT_ID, }); - const sandbox = await manager.provision(branchName, requirementsMd, env.GITHUB_BASE_BRANCH); - return runAgent({ - sandbox, - manager, - model: env.CLAUDE_MODEL, - debug: env.DEBUG_AGENT, - }); + const sandbox = await manager.provision(branchName, requirementsMd, mergeBase); + await startAgentDetached(sandbox); + return sandbox.sandboxId; } +provisionAndStartFixingAgent.maxRetries = 0; async function pushChanges( branchName: string, @@ -155,32 +145,73 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { hasConflicts, ); - const { output, files } = await runFixingAgentInSandbox( + // --- Detached execution with polling --- + const { checkAgentDone, collectAgentResults, teardownSandbox } = + await import("../sandbox/poll-agent.js"); + + const sandboxId = await provisionAndStartFixingAgent( branchName, requirementsMd, + env.GITHUB_BASE_BRANCH, ); - await pushChanges(branchName, files, baseSha); - - if (output.result === "implemented") { - await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + // Poll until agent finishes — use iteration counter for deterministic WDK replay. + const POLL_INTERVAL = "30s"; + const MAX_POLLS = Math.ceil((35 * 60) / 30); // ~70 iterations ≈ 35 min + let pollCount = 0; + let agentDone = false; + + try { + while (!agentDone) { + await sleep(POLL_INTERVAL); + pollCount++; + + if (pollCount >= MAX_POLLS) break; + + const status = await checkAgentDone(sandboxId); + if (status === true) { + agentDone = true; + } else if (status === "stopped") { + break; + } + } + + let output: AgentOutput; + let files: Array<{ path: string; content: string }>; + + if (agentDone) { + ({ output, files } = await collectAgentResults(sandboxId)); + } else { + output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; + files = []; + } + + await pushChanges(branchName, files, baseSha); + + if (output.result === "implemented") { + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + await notifySlack( + `Task ${ticket.identifier} fixes applied, ready for re-review`, + ); + await unregisterRun(ticket.identifier); + return; + } + + await moveTicket(ticketId, env.COLUMN_BACKLOG); await notifySlack( - `Task ${ticket.identifier} fixes applied, ready for re-review`, + `Task ${ticket.identifier} review-fix failed: ${output.error ?? "unknown error"}`, ); await unregisterRun(ticket.identifier); - return; + } finally { + await teardownSandbox(sandboxId); } - - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack( - `Task ${ticket.identifier} review-fix failed: ${output.error ?? "unknown error"}`, - ); - await unregisterRun(ticket.identifier); } catch (err) { console.error(`Workflow failed for ${ticket.identifier}:`, err); - await moveTicket(ticketId, env.COLUMN_BACKLOG).catch(() => {}); + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - await unregisterRun(ticket.identifier).catch(() => {}); + if (moved) { + await unregisterRun(ticket.identifier).catch(() => {}); + } throw err; } } From c2a6f15daa9bf75b16d8c0c3f91b386034a5eb20 Mon Sep 17 00:00:00 2001 From: ai-workflow-blazity Date: Thu, 2 Apr 2026 16:08:07 +0200 Subject: [PATCH 02/71] Aiw 25 safeguard (#37) * feat: add redis safeguard * fix: json parse --------- Co-authored-by: kasin-it --- ...26-04-02-failed-ticket-safeguard-design.md | 147 ++++++++++++++++++ src/adapters/run-registry/types.ts | 15 ++ src/adapters/run-registry/upstash.test.ts | 65 ++++++++ src/adapters/run-registry/upstash.ts | 25 ++- src/lib/cancel-run.test.ts | 4 + src/lib/dispatch.test.ts | 85 ++++++++++ src/lib/dispatch.ts | 7 +- src/lib/reconcile.test.ts | 29 ++++ src/lib/reconcile.ts | 9 ++ src/workflows/implementation.ts | 18 ++- src/workflows/review-fix.ts | 14 ++ 11 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-02-failed-ticket-safeguard-design.md diff --git a/docs/superpowers/specs/2026-04-02-failed-ticket-safeguard-design.md b/docs/superpowers/specs/2026-04-02-failed-ticket-safeguard-design.md new file mode 100644 index 0000000..0ece110 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-failed-ticket-safeguard-design.md @@ -0,0 +1,147 @@ +# Failed Ticket Safeguard Design + +## Problem + +When a workflow fails and the catch block tries to move the ticket to backlog via Jira, that move can also fail (e.g., Jira outage, permission error, network timeout). When this happens: + +1. The ticket remains in the AI column in Jira +2. The Redis run entry is preserved (by design — `unregisterRun` is skipped when move fails) +3. The WDK run is marked as `failed` +4. Reconciliation detects the `failed` run and unregisters it from Redis +5. Next poll cycle rediscovers the ticket in the AI column and dispatches it again +6. The workflow fails again for the same reason — **infinite loop** + +## Solution + +Add a "failed ticket" marker in Redis. Before dispatching a ticket, check if it's marked as failed. If so, skip it. The marker is cleared when the ticket leaves the AI column (detected by the existing reconciliation loop), meaning a human just needs to move the ticket out and back in to retry. + +## Scope + +The failure marker is **only** written when `moveTicket` to backlog fails in the workflow catch block. If `moveTicket` succeeds, the ticket is safely in backlog and won't be rediscovered by the poll — no marker needed. + +## Redis Data Model + +**Hash key:** `blazebot:failed-tickets:{ENV_PREFIX}` + +Follows the same pattern as the existing `blazebot:active-runs:{ENV_PREFIX}` hash. + +**Field:** Ticket key (e.g., `AWT-42`) + +**Value:** JSON string with error context: + +```json +{ + "runId": "run_abc123", + "error": "Failed to move ticket to backlog: 403 Forbidden", + "failedAt": "2026-04-02T12:34:56.000Z" +} +``` + +No TTL on the hash — entries are explicitly removed during reconciliation. + +## Write Path — Marking Failures + +In `src/workflows/implementation.ts`, the existing catch block is modified: + +```typescript +catch (err) { + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG) + .then(() => true) + .catch(() => false); + if (moved) { + await unregisterRun(ticket.identifier).catch(() => {}); + } else { + await markTicketFailed(ticket.identifier, runId, err).catch(() => {}); + } + throw err; +} +``` + +`markTicketFailed` writes to the `blazebot:failed-tickets` hash. It is `.catch(() => {})`-guarded because if even this Redis write fails, we still want to re-throw the original error. Reconciliation will eventually handle the stale run. + +The same pattern is applied to `src/workflows/review-fix.ts` if it has an equivalent catch block. + +## Read Path — Skipping Failed Tickets + +In `src/lib/dispatch.ts`, before the atomic `claim` call: + +```typescript +const isFailed = await runRegistry.isTicketFailed(ticketKey); +if (isFailed) { + return { started: false, reason: "previously_failed" }; +} +``` + +This is a single `hget` call before `claim`, avoiding wasted claim attempts on tickets known to be stuck. The `"previously_failed"` reason surfaces in poll response logs. + +## Clear Path — Reconciliation Cleanup + +In `src/lib/reconcile.ts`, after the existing reconciliation logic, iterate the `blazebot:failed-tickets` hash: + +```typescript +const failedTickets = await runRegistry.listAllFailed(); +for (const { ticketKey } of failedTickets) { + if (!aiColumnTicketKeys.has(ticketKey)) { + await runRegistry.clearFailedMark(ticketKey); + } +} +``` + +When a ticket leaves the AI column (moved by a human), the next reconciliation pass removes the failure marker. If the ticket is later moved back to AI, it gets a fresh dispatch attempt. + +## RunRegistryAdapter Interface Changes + +Three new methods added to the existing interface in `src/adapters/run-registry/types.ts` (or wherever the interface is defined): + +```typescript +interface RunRegistryAdapter { + // ... existing methods (claim, register, getRunId, unregister, listAll) ... + + markFailed(ticketKey: string, meta: { runId: string; error: string; failedAt: string }): Promise; + isTicketFailed(ticketKey: string): Promise; + listAllFailed(): Promise>; + clearFailedMark(ticketKey: string): Promise; +} +``` + +## Upstash Implementation + +In `src/adapters/run-registry/upstash.ts`: + +| Method | Redis Operation | +|--------|----------------| +| `markFailed` | `hset("blazebot:failed-tickets:{ENV}", ticketKey, JSON.stringify(meta))` | +| `isTicketFailed` | `hget(...)` returns truthy/falsy | +| `listAllFailed` | `hgetall(...)` with JSON.parse on values | +| `clearFailedMark` | `hdel(...)` | + +All follow the exact same Redis patterns already used for active-runs. + +## Full Flow + +1. **Workflow fails** — catch block tries `moveTicket` to backlog +2. **If move fails** — `markTicketFailed()` writes to `blazebot:failed-tickets` hash +3. **Next poll** — `dispatchDiscoveredTickets` calls `isTicketFailed()` — skips with `"previously_failed"` +4. **Human moves ticket out of AI column** — reconciliation calls `clearFailedMark()` — marker removed +5. **Human moves ticket back to AI** — dispatched fresh, no marker blocking it + +## Testing + +- Unit test: `markFailed` writes correct JSON to Redis hash +- Unit test: `isTicketFailed` returns `true` when marker exists, `false` when absent +- Unit test: `clearFailedMark` removes the entry +- Integration test: dispatch skips a ticket with a failure marker (returns `"previously_failed"`) +- Integration test: reconciliation clears failure marker when ticket leaves AI column +- Integration test: full loop — fail + move fails → marked → skipped → moved out → cleared → redispatched + +## Files to Modify + +| File | Change | +|------|--------| +| `src/adapters/run-registry/upstash.ts` | Add `markFailed`, `isTicketFailed`, `listAllFailed`, `clearFailedMark` | +| `src/adapters/run-registry/types.ts` | Extend `RunRegistryAdapter` interface | +| `src/workflows/implementation.ts` | Add `markTicketFailed` call in catch block | +| `src/workflows/review-fix.ts` | Same catch block change (if applicable) | +| `src/lib/dispatch.ts` | Add `isTicketFailed` check before `claim` | +| `src/lib/reconcile.ts` | Add failed-ticket cleanup pass | +| Tests for each of the above | New test cases | diff --git a/src/adapters/run-registry/types.ts b/src/adapters/run-registry/types.ts index 8b600d1..2db99d8 100644 --- a/src/adapters/run-registry/types.ts +++ b/src/adapters/run-registry/types.ts @@ -1,3 +1,9 @@ +export interface FailedTicketMeta { + runId: string; + error: string; + failedAt: string; +} + export interface RunRegistryAdapter { /** Atomically claim a ticket key if not already taken. Returns true if claimed. */ claim(ticketKey: string, runId: string): Promise; @@ -9,4 +15,13 @@ export interface RunRegistryAdapter { unregister(ticketKey: string): Promise; /** Get all tracked ticket -> runId pairs. */ listAll(): Promise>; + + /** Mark a ticket as failed (moveTicket to backlog failed in catch block). */ + markFailed(ticketKey: string, meta: FailedTicketMeta): Promise; + /** Check if a ticket has a failure marker. */ + isTicketFailed(ticketKey: string): Promise; + /** List all failed ticket markers. */ + listAllFailed(): Promise>; + /** Remove the failure marker for a ticket. */ + clearFailedMark(ticketKey: string): Promise; } diff --git a/src/adapters/run-registry/upstash.test.ts b/src/adapters/run-registry/upstash.test.ts index 7e4184d..64251ee 100644 --- a/src/adapters/run-registry/upstash.test.ts +++ b/src/adapters/run-registry/upstash.test.ts @@ -99,4 +99,69 @@ describe("UpstashRunRegistry", () => { expect(result).toEqual([]); }); }); + + const FAILED_HASH_KEY = `blazebot:failed-tickets:${process.env.VERCEL_ENV ?? "development"}`; + + describe("markFailed", () => { + it("stores failure metadata in the failed-tickets hash", async () => { + const registry = createRegistry(); + const meta = { + runId: "run_abc", + error: "Failed to move ticket to backlog: 403 Forbidden", + failedAt: "2026-04-02T12:34:56.000Z", + }; + await registry.markFailed("AWT-42", meta); + expect(mockRedis.hset).toHaveBeenCalledWith(FAILED_HASH_KEY, { + "AWT-42": JSON.stringify(meta), + }); + }); + }); + + describe("isTicketFailed", () => { + it("returns true when a failure marker exists", async () => { + mockRedis.hget.mockResolvedValueOnce('{"runId":"run_abc","error":"err","failedAt":"2026-04-02T12:34:56.000Z"}'); + const registry = createRegistry(); + const result = await registry.isTicketFailed("AWT-42"); + expect(result).toBe(true); + expect(mockRedis.hget).toHaveBeenCalledWith(FAILED_HASH_KEY, "AWT-42"); + }); + + it("returns false when no failure marker exists", async () => { + mockRedis.hget.mockResolvedValueOnce(null); + const registry = createRegistry(); + const result = await registry.isTicketFailed("AWT-99"); + expect(result).toBe(false); + }); + }); + + describe("listAllFailed", () => { + it("returns all failed ticket markers", async () => { + mockRedis.hgetall.mockResolvedValueOnce({ + "AWT-1": '{"runId":"run_a","error":"err1","failedAt":"2026-04-02T10:00:00.000Z"}', + "AWT-2": '{"runId":"run_b","error":"err2","failedAt":"2026-04-02T11:00:00.000Z"}', + }); + const registry = createRegistry(); + const result = await registry.listAllFailed(); + expect(result).toEqual([ + { ticketKey: "AWT-1", meta: { runId: "run_a", error: "err1", failedAt: "2026-04-02T10:00:00.000Z" } }, + { ticketKey: "AWT-2", meta: { runId: "run_b", error: "err2", failedAt: "2026-04-02T11:00:00.000Z" } }, + ]); + expect(mockRedis.hgetall).toHaveBeenCalledWith(FAILED_HASH_KEY); + }); + + it("returns empty array when no failed tickets", async () => { + mockRedis.hgetall.mockResolvedValueOnce(null); + const registry = createRegistry(); + const result = await registry.listAllFailed(); + expect(result).toEqual([]); + }); + }); + + describe("clearFailedMark", () => { + it("removes the failure marker from the hash", async () => { + const registry = createRegistry(); + await registry.clearFailedMark("AWT-42"); + expect(mockRedis.hdel).toHaveBeenCalledWith(FAILED_HASH_KEY, "AWT-42"); + }); + }); }); diff --git a/src/adapters/run-registry/upstash.ts b/src/adapters/run-registry/upstash.ts index 2800f15..4449721 100644 --- a/src/adapters/run-registry/upstash.ts +++ b/src/adapters/run-registry/upstash.ts @@ -1,8 +1,9 @@ import { Redis } from "@upstash/redis"; -import type { RunRegistryAdapter } from "./types.js"; +import type { RunRegistryAdapter, FailedTicketMeta } from "./types.js"; const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; const HASH_KEY = `blazebot:active-runs:${ENV_PREFIX}`; +const FAILED_HASH_KEY = `blazebot:failed-tickets:${ENV_PREFIX}`; export class UpstashRunRegistry implements RunRegistryAdapter { private redis: Redis; @@ -35,4 +36,26 @@ export class UpstashRunRegistry implements RunRegistryAdapter { if (!all) return []; return Object.entries(all).map(([ticketKey, runId]) => ({ ticketKey, runId })); } + + async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { + await this.redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); + } + + async isTicketFailed(ticketKey: string): Promise { + const value = await this.redis.hget(FAILED_HASH_KEY, ticketKey); + return value != null; + } + + async listAllFailed(): Promise> { + const all = await this.redis.hgetall>(FAILED_HASH_KEY); + if (!all) return []; + return Object.entries(all).map(([ticketKey, raw]) => ({ + ticketKey, + meta: (typeof raw === "string" ? JSON.parse(raw) : raw) as FailedTicketMeta, + })); + } + + async clearFailedMark(ticketKey: string): Promise { + await this.redis.hdel(FAILED_HASH_KEY, ticketKey); + } } diff --git a/src/lib/cancel-run.test.ts b/src/lib/cancel-run.test.ts index 5e6dbc7..73bf7f0 100644 --- a/src/lib/cancel-run.test.ts +++ b/src/lib/cancel-run.test.ts @@ -13,6 +13,10 @@ function makeRegistry(overrides: Partial = {}): RunRegistryA getRunId: vi.fn(), unregister: overrides.unregister ?? vi.fn().mockResolvedValue(undefined), listAll: vi.fn(), + markFailed: vi.fn().mockResolvedValue(undefined), + isTicketFailed: vi.fn().mockResolvedValue(false), + listAllFailed: vi.fn().mockResolvedValue([]), + clearFailedMark: vi.fn().mockResolvedValue(undefined), }; } diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 1703f39..61f9b06 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -46,6 +46,7 @@ function makeAdapters( getRunId: ReturnType; fetchTicket: ReturnType; findPR: ReturnType; + isTicketFailed: ReturnType; }> = {}, ): Adapters { let claimedValue: string | undefined; @@ -83,6 +84,10 @@ function makeAdapters( overrides.getRunId ?? vi.fn().mockImplementation(async () => claimedValue), listAll: vi.fn(), + markFailed: vi.fn().mockResolvedValue(undefined), + isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false), + listAllFailed: vi.fn().mockResolvedValue([]), + clearFailedMark: vi.fn().mockResolvedValue(undefined), }, }; } @@ -237,4 +242,84 @@ describe("dispatchTicket", () => { expect(unregister).toHaveBeenCalledWith("PROJ-42"); expect(mockStart).not.toHaveBeenCalled(); }); + + it("skips dispatch for previously failed tickets", async () => { + const adapters = makeAdapters({ + isTicketFailed: vi.fn().mockResolvedValue(true), + }); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 5); + + expect(result).toEqual({ started: false, reason: "previously_failed" }); + expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); + expect(mockStart).not.toHaveBeenCalled(); + }); +}); + +describe("failed-ticket safeguard full loop", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSandboxList.mockResolvedValue({ json: { sandboxes: [] } }); + mockStart.mockResolvedValue({ runId: "run_123" }); + }); + + it("mark → skip → clear → redispatch", async () => { + // Shared mutable state simulating Redis + const failedMarkers = new Map(); + let claimedValue: string | undefined; + + const registry: Adapters["runRegistry"] = { + claim: vi.fn().mockImplementation(async (_key: string, value: string) => { + claimedValue = value; + return true; + }), + register: vi.fn().mockResolvedValue(undefined), + getRunId: vi.fn().mockImplementation(async () => claimedValue), + unregister: vi.fn().mockResolvedValue(undefined), + listAll: vi.fn().mockResolvedValue([]), + markFailed: vi.fn().mockImplementation(async (key: string, meta: any) => { + failedMarkers.set(key, JSON.stringify(meta)); + }), + isTicketFailed: vi.fn().mockImplementation(async (key: string) => { + return failedMarkers.has(key); + }), + listAllFailed: vi.fn().mockImplementation(async () => { + return [...failedMarkers.entries()].map(([ticketKey, raw]) => ({ + ticketKey, + meta: JSON.parse(raw), + })); + }), + clearFailedMark: vi.fn().mockImplementation(async (key: string) => { + failedMarkers.delete(key); + }), + }; + + const adapters = makeAdapters(); + // Replace registry with our stateful mock + Object.assign(adapters.runRegistry, registry); + + const { dispatchTicket } = await import("./dispatch.js"); + const { reconcileRuns } = await import("./reconcile.js"); + + // Step 1: Mark ticket as failed (simulates workflow catch block) + await registry.markFailed("PROJ-42", { + runId: "run_failed", + error: "move failed", + failedAt: "2026-04-02T10:00:00.000Z", + }); + + // Step 2: Dispatch is skipped because ticket is marked failed + const skip = await dispatchTicket("PROJ-42", adapters, 5); + expect(skip).toEqual({ started: false, reason: "previously_failed" }); + + // Step 3: Human moves ticket out of AI column → reconcile clears marker + await reconcileRuns(new Set(), registry); + expect(failedMarkers.has("PROJ-42")).toBe(false); + + // Step 4: Ticket moved back to AI → fresh dispatch succeeds + const success = await dispatchTicket("PROJ-42", adapters, 5); + expect(success.started).toBe(true); + expect(success.runId).toBe("run_123"); + }); }); diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index d173a10..6ef0265 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -17,7 +17,7 @@ export function getClaimTimestamp(runId: string): number { export interface DispatchResult { started: boolean; runId?: string; - reason?: "already_claimed" | "at_capacity" | "error"; + reason?: "already_claimed" | "at_capacity" | "error" | "previously_failed"; } export async function dispatchTicket( @@ -27,6 +27,11 @@ export async function dispatchTicket( ): Promise { const { issueTracker, vcs, runRegistry } = adapters; + if (await runRegistry.isTicketFailed(ticketKey)) { + logger.info({ ticketKey }, "dispatch_skipped_previously_failed"); + return { started: false, reason: "previously_failed" }; + } + if (await isAtCapacity(maxConcurrentAgents)) { return { started: false, reason: "at_capacity" }; } diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index aef79d9..eef24ac 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -13,6 +13,7 @@ vi.mock("./cancel-run.js", () => ({ function makeRegistry( runs: Array<{ ticketKey: string; runId: string }> = [], + failed: Array<{ ticketKey: string; meta: { runId: string; error: string; failedAt: string } }> = [], ): RunRegistryAdapter { return { claim: vi.fn(), @@ -20,6 +21,10 @@ function makeRegistry( getRunId: vi.fn(), unregister: vi.fn().mockResolvedValue(undefined), listAll: vi.fn().mockResolvedValue(runs), + markFailed: vi.fn().mockResolvedValue(undefined), + isTicketFailed: vi.fn().mockResolvedValue(false), + listAllFailed: vi.fn().mockResolvedValue(failed), + clearFailedMark: vi.fn().mockResolvedValue(undefined), }; } @@ -181,4 +186,28 @@ describe("reconcileRuns", () => { await reconcileRuns(new Set(["PROJ-1"]), registry); expect(registry.unregister).not.toHaveBeenCalled(); }); + + it("clears failed-ticket marker when ticket leaves AI column", async () => { + const registry = makeRegistry([], [ + { ticketKey: "PROJ-1", meta: { runId: "run_a", error: "move failed", failedAt: "2026-04-02T10:00:00.000Z" } }, + ]); + const { reconcileRuns } = await import("./reconcile.js"); + + // PROJ-1 is NOT in AI column — human moved it out + await reconcileRuns(new Set(), registry); + + expect(registry.clearFailedMark).toHaveBeenCalledWith("PROJ-1"); + }); + + it("keeps failed-ticket marker when ticket is still in AI column", async () => { + const registry = makeRegistry([], [ + { ticketKey: "PROJ-1", meta: { runId: "run_a", error: "move failed", failedAt: "2026-04-02T10:00:00.000Z" } }, + ]); + const { reconcileRuns } = await import("./reconcile.js"); + + // PROJ-1 still in AI column + await reconcileRuns(new Set(["PROJ-1"]), registry); + + expect(registry.clearFailedMark).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index fdbbd39..f6b5c59 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -48,6 +48,15 @@ export async function reconcileRuns( } } + // Clean up failed-ticket markers for tickets that left the AI column + const failedTickets = await runRegistry.listAllFailed(); + for (const { ticketKey } of failedTickets) { + if (!aiColumnTickets.has(ticketKey)) { + await runRegistry.clearFailedMark(ticketKey); + logger.info({ ticketKey }, "reconcile_cleared_failed_mark"); + } + } + return { cancelled, cleaned }; } diff --git a/src/workflows/implementation.ts b/src/workflows/implementation.ts index 1edf47d..5cb807d 100644 --- a/src/workflows/implementation.ts +++ b/src/workflows/implementation.ts @@ -125,6 +125,18 @@ async function unregisterRun(ticketIdentifier: string) { await runRegistry.unregister(ticketIdentifier); } +async function markTicketFailed(ticketIdentifier: string, error: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; + await runRegistry.markFailed(ticketIdentifier, { + runId, + error, + failedAt: new Date().toISOString(), + }); +} + // --- Workflow (durable orchestration — no I/O directly here) --- export async function implementationWorkflow(ticketId: string) { @@ -213,12 +225,10 @@ export async function implementationWorkflow(ticketId: string) { console.error(`Workflow failed for ${ticket.identifier}:`, err); const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - // Only unregister if the ticket was moved out of AI column. - // If moveTicket failed, leave the Redis entry so the cron doesn't - // dispatch a duplicate — reconcile will clean it up once the ticket - // is manually moved or the run becomes terminal. if (moved) { await unregisterRun(ticket.identifier).catch(() => {}); + } else { + await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); } throw err; } diff --git a/src/workflows/review-fix.ts b/src/workflows/review-fix.ts index b03e251..c4b17d6 100644 --- a/src/workflows/review-fix.ts +++ b/src/workflows/review-fix.ts @@ -122,6 +122,18 @@ async function unregisterRun(ticketIdentifier: string) { await runRegistry.unregister(ticketIdentifier); } +async function markTicketFailed(ticketIdentifier: string, error: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; + await runRegistry.markFailed(ticketIdentifier, { + runId, + error, + failedAt: new Date().toISOString(), + }); +} + // --- Workflow --- export async function reviewFixWorkflow(ticketId: string, branchName: string) { @@ -211,6 +223,8 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); if (moved) { await unregisterRun(ticket.identifier).catch(() => {}); + } else { + await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); } throw err; } From e0cdf4b4670c473debfbbc103fbc9f5903d5be91 Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:08:45 +0200 Subject: [PATCH 03/71] feat: push inside the sandbox (#40) --- .../specs/2026-04-02-sandbox-push-design.md | 334 ++++++++++++++++++ src/lib/prompts.ts | 28 +- src/sandbox/manager.ts | 14 +- src/sandbox/poll-agent.test.ts | 182 +++++++++- src/sandbox/poll-agent.ts | 135 +++++-- src/workflows/implementation.ts | 32 +- src/workflows/review-fix.ts | 44 +-- 7 files changed, 673 insertions(+), 96 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-02-sandbox-push-design.md diff --git a/docs/superpowers/specs/2026-04-02-sandbox-push-design.md b/docs/superpowers/specs/2026-04-02-sandbox-push-design.md new file mode 100644 index 0000000..d3b3620 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-sandbox-push-design.md @@ -0,0 +1,334 @@ +# Sandbox Push — Push from Sandbox with Real Commit Messages + +**Date:** 2026-04-02 +**Status:** Draft + +## Problem + +Today, the agent commits inside the sandbox with real commit messages, but the server +throws them away — it extracts files via `git diff`, then recreates a single commit via +the GitHub API with a hardcoded `"feat: agent implementation"` message. This means: + +1. **All agent commit messages are lost.** The PR always shows one flat commit. +2. **Pre-push hooks never run.** The GitHub API bypasses git hooks entirely. +3. **The GitHub token lives in the sandbox** as part of the clone URL in `.git/config`, + potentially visible to the agent. + +## Solution + +Push from inside the sandbox after the agent exits. The agent commits and pushes to a +local mock remote (triggering pre-push hooks naturally). After the agent process is dead, +the server injects the GitHub token and does the real `git push` to GitHub. + +## Detailed Flow + +### 1. Branch Creation (unchanged — server-side) + +The server creates the branch via GitHub API before the sandbox is provisioned. +This requires the token and stays on the server. + +``` +Server: vcs.createBranch("blazebot/awt-123", "main") // Octokit API +``` + +**Files:** `src/workflows/implementation.ts:19-24`, `src/adapters/vcs/github.ts:23-51` +**Change:** None. + +### 2. Sandbox Provisioning (modified) + +After `Sandbox.create` clones the repo, immediately sanitize the remote and set up +a local push target. This ensures the agent never sees the GitHub token. + +```bash +# Remove origin (may contain token from clone) +git remote remove origin + +# Create local bare repo as push target +git init --bare /tmp/push-target.git +git remote add origin /tmp/push-target.git +``` + +**File:** `src/sandbox/manager.ts` (after line 62, before git config) +**Change:** Add remote sanitization + local bare repo setup after sandbox creation. + +### 3. Git Identity + Optional Merge (unchanged) + +```bash +git config user.name "ai-workflow-blazity" +git config user.email "ai-workflow@blazity.com" +``` + +For review-fix workflow, the merge fetch still uses an authenticated URL passed as a +CLI argument to `git fetch`. This appears briefly in process args but is not stored. +No change needed — the agent process hasn't started yet. + +**File:** `src/sandbox/manager.ts:64-91` +**Change:** None. + +### 4. Pre-Agent SHA Recording (unchanged) + +```bash +git rev-parse HEAD > /tmp/.pre-agent-sha +``` + +**File:** `src/sandbox/manager.ts:93-98` +**Change:** None. + +### 5. Agent Execution (modified prompt) + +The implementation and review-fix prompts gain a quality gate instruction and a push +instruction. The agent now pushes to `origin` (which points to the local bare repo). + +**Prompt additions (both `implement.md` and `review-fix.md`):** + +``` +## Quality Gate + +Before finishing, you MUST: +- Find and run ALL quality checks in the project: tests, linting, type checking, + formatting, and any other validation scripts. +- Fix all failures and commit your fixes with descriptive messages. +- Push your work to origin (`git push origin `). + - If the push fails due to pre-push hooks, fix the issues, commit, and push again. + - If the push succeeds, you are clear to finish. +``` + +**Prompt modification for commit messages:** + +Replace the existing line: +``` +10. Commit your work with descriptive commit messages. +``` + +With: +``` +10. Commit your work with descriptive commit messages that explain the "why", not just + the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). +11. Run all quality checks and push (see Quality Gate above). +``` + +**Files:** `src/lib/prompts.ts` (both `implementPrompt` and `reviewFixPrompt`) + +### 6. Agent Works + +The agent implements the feature, committing with real messages: + +``` +git commit -m "feat: add user validation schema" +git commit -m "feat: implement registration endpoint" +git commit -m "fix: handle duplicate email edge case" +git commit -m "test: add registration tests" +``` + +Then pushes: + +``` +git push origin blazebot/awt-123 + → .husky/pre-push fires (if present) → runs lint/test + → If hook fails → agent fixes, commits, pushes again + → If hook passes → push succeeds to /tmp/push-target.git +``` + +The agent exits, wrapper script touches `/tmp/agent-done`. + +### 7. Collect Agent Output (simplified) + +Only read the agent's JSON output. **Remove file extraction entirely** — no more +`git diff` + `readFileToBuffer` loop. + +**Before:** +```typescript +// poll-agent.ts collectAgentResults() +// Reads stdout, parses JSON, extracts files via git diff, reads each file content +``` + +**After:** +```typescript +// poll-agent.ts collectAgentOutput() +// Reads stdout, parses JSON — that's it +// Returns: { output: AgentOutput } (no files array) +``` + +**File:** `src/sandbox/poll-agent.ts:36-98` +**Change:** Remove lines 63-94 (file extraction). Rename to `collectAgentOutput`. +Return type changes from `{ output, files }` to `{ output }`. + +### 8. Push from Sandbox (new step) + +After the agent exits and output is collected, inject the token and push. + +```typescript +async function pushFromSandbox( + sandboxId: string, + branch: string, +): Promise<{ pushed: boolean; error?: string }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { env } = await import("../../env.js"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Check if agent made any commits + const baseSha = await sandbox.runCommand("bash", [ + "-c", "cat /tmp/.pre-agent-sha", + ]); + const headSha = await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD"]); + + if ((await baseSha.stdout()).trim() === (await headSha.stdout()).trim()) { + return { pushed: false }; // No commits to push + } + + // Inject token — agent process is dead + const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; + await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + + // Push to GitHub + const result = await sandbox.runCommand("bash", [ + "-c", `git push origin ${branch} 2>&1`, + ]); + + if (result.exitCode !== 0) { + const error = (await result.stdout()).trim(); + return { pushed: false, error }; + } + + return { pushed: true }; +} +``` + +**File:** New function in `src/sandbox/poll-agent.ts` (or new file `src/sandbox/push.ts`) + +### 9. Fix Agent on Push Failure (new step) + +If `pushFromSandbox` fails (e.g., pre-push hook failure), spawn a lightweight fix +agent in the same sandbox to resolve the issue. + +```typescript +async function fixAndRetryPush( + sandboxId: string, + branch: string, + pushError: string, +): Promise<{ pushed: boolean; error?: string }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Run a quick fix agent with the error context + const fixPrompt = `The git push failed with this error:\n\n${pushError}\n\nFix the issues, commit your fixes, then push to origin.`; + await sandbox.runCommand("bash", [ + "-c", + `echo '${fixPrompt.replace(/'/g, "'\\''")}' | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, + ]); + + // Retry push (token is still in remote URL from previous step) + const result = await sandbox.runCommand("bash", [ + "-c", `git push origin ${branch} 2>&1`, + ]); + + if (result.exitCode !== 0) { + return { pushed: false, error: (await result.stdout()).trim() }; + } + return { pushed: true }; +} +``` + +**File:** New function alongside `pushFromSandbox`. + +### 10. Workflow Integration + +**Implementation workflow** (`src/workflows/implementation.ts`): + +Replace: +```typescript +// Old +const { output, files } = await collectAgentResults(sandboxId); +await pushChanges(branchName, files); +``` + +With: +```typescript +// New +const { output } = await collectAgentOutput(sandboxId); + +if (output.result === "implemented") { + let pushResult = await pushFromSandbox(sandboxId, branchName); + + if (!pushResult.pushed && pushResult.error) { + // Pre-push hook or other failure — try fix agent + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + } + + if (!pushResult.pushed) { + // Push failed even after fix attempt + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + await createPullRequest(branchName, ticket.title, output.summary ?? ""); + // ... rest unchanged +} +``` + +**Review-fix workflow** (`src/workflows/review-fix.ts`): + +Same pattern. Replace `collectAgentResults` + `pushChanges` with +`collectAgentOutput` + `pushFromSandbox` + `fixAndRetryPush`. + +**Note on merge commits:** The current review-fix flow uses `mergeParentSha` to create +a merge commit via the GitHub API. With sandbox push, this is handled naturally — the +sandbox already has the merge commit from step 3 (optional merge). When we `git push`, +the merge commit is included in the push. The `mergeParentSha` parameter on +`github.ts:push()` is no longer needed. + +### 11. PR Creation, Ticket Update, Teardown (unchanged) + +PR creation still uses Octokit API (no token needed in sandbox for this). +Ticket moves and Slack notifications are unchanged. + +## Files Changed + +| File | Change | +|------|--------| +| `src/sandbox/manager.ts` | Add remote sanitization + local bare repo setup after clone | +| `src/lib/prompts.ts` | Add quality gate + push instructions to both prompts | +| `src/sandbox/poll-agent.ts` | Simplify `collectAgentResults` → `collectAgentOutput` (remove file extraction). Add `pushFromSandbox` + `fixAndRetryPush` | +| `src/workflows/implementation.ts` | Replace `pushChanges(files)` with `pushFromSandbox` + `fixAndRetryPush` | +| `src/workflows/review-fix.ts` | Same as implementation.ts | +| `src/adapters/vcs/github.ts` | `push()` method no longer called for agent work (keep for other uses or remove) | + +## What's Preserved + +- All agent commits with their original messages +- Full commit history on the PR (not squashed) +- Pre-push hooks fire naturally during `git push` +- Agent can fix hook failures before exiting +- Merge commits in review-fix flow (natural git merge, not API-fabricated) + +## What's Removed + +- File extraction in `collectAgentResults` (`git diff` + `readFileToBuffer` loop) +- Hardcoded `"feat: agent implementation"` commit message +- GitHub API push flow (blob → tree → commit → updateRef) for agent work + +## Edge Cases + +| Case | Handling | +|------|----------| +| No pre-push hooks in target repo | Push to local bare succeeds, real push succeeds. No change. | +| Agent doesn't push (forgets/skips) | Commits are still in sandbox. `pushFromSandbox` pushes them. | +| Pre-push hook fails during real push | `fixAndRetryPush` spawns fix agent, retries once. | +| Fix agent also fails | Move ticket to backlog with error details. | +| Sandbox dies between agent exit and push | Existing `"stopped"` detection catches this — ticket moves to backlog. | +| Agent makes zero commits | `pushFromSandbox` detects baseSha == HEAD, returns `{ pushed: false }`. Workflow handles as no-op or failure based on agent output. | +| Shallow clone push to GitHub | Works — GitHub has the parent commit (branch was created from base). Git sends the delta. | +| Token in .git/config from clone | Removed in step 2 (`git remote remove origin`). | + +## Security + +- **Agent never sees the GitHub token.** Remote is sanitized immediately after clone. +- **Token injected only after agent process exits.** The sentinel file `/tmp/agent-done` + confirms the agent is dead before any token enters the sandbox. +- **Token exists briefly** in the sandbox git config during step 10, then sandbox is torn down. +- **Fix agent (step 9)** runs with the token in git config, but this is a controlled, + short-lived session with a narrow prompt — not the main agent with full autonomy. diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 0ddc267..11157ce 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -47,7 +47,9 @@ You have access to **superpowers skills** installed globally. Use them — they 7. Self-review your changes for quality, correctness, and completeness. 8. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. 9. **Update session memory** — write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -10. Commit your work with descriptive commit messages. +10. Commit your work with descriptive commit messages that explain the "why", not just + the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). +11. Run all quality checks and push (see Quality Gate below). ## When to Ask for Clarification @@ -106,6 +108,16 @@ Use this format: Keep the memory concise and factual. This file will be read by future agent sessions (including review-fix agents) to restore context. +## Quality Gate + +Before finishing, you MUST: +- Find and run ALL quality checks in the project: tests, linting, type checking, + formatting, and any other validation scripts. +- Fix all failures and commit your fixes with descriptive messages. +- Push your work to origin (\`git push origin \`). + - If the push fails due to pre-push hooks, fix the issues, commit, and push again. + - If the push succeeds, you are clear to finish. + ## Output Return a JSON object with: @@ -152,7 +164,9 @@ You have access to **superpowers skills** installed globally. Use them to improv 5. Self-review your changes. 6. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. 7. **Update session memory** — before returning your result, write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -8. Commit your work with descriptive commit messages. +8. Commit your work with descriptive commit messages that explain the "why", not just + the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). +9. Run all quality checks and push (see Quality Gate below). ## Comment Overrides @@ -190,6 +204,16 @@ Use this format: Keep the memory concise and factual. This file persists across sessions and serves as context for future runs. +## Quality Gate + +Before finishing, you MUST: +- Find and run ALL quality checks in the project: tests, linting, type checking, + formatting, and any other validation scripts. +- Fix all failures and commit your fixes with descriptive messages. +- Push your work to origin (\`git push origin \`). + - If the push fails due to pre-push hooks, fix the issues, commit, and push again. + - If the push succeeds, you are clear to finish. + ## Output Return a JSON object with: diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index dfbe49b..f2582f2 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -61,6 +61,18 @@ export class SandboxManager { }, }); + // Sanitize remote — remove origin (may contain token from clone) and + // set up a local bare repo as the push target so the agent never sees + // the GitHub token. Pre-push hooks fire naturally on `git push`. + await sandbox.runCommand("bash", [ + "-c", + [ + "git remote remove origin", + "git init --bare /tmp/push-target.git", + "git remote add origin /tmp/push-target.git", + ].join(" && "), + ]); + // Configure git identity await sandbox.runCommand("bash", [ "-c", @@ -90,7 +102,7 @@ export class SandboxManager { } } - // Record the pre-agent HEAD so the poll step (collectAgentResults) can diff only agent work. + // Record the pre-agent HEAD so pushFromSandbox can detect whether the agent made commits. // Must happen after clone + optional merge, before the agent touches anything. await sandbox.runCommand("bash", [ "-c", diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index 944f3ea..bb911c9 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockRunCommand = vi.fn(); const mockReadFileToBuffer = vi.fn(); +const mockWriteFiles = vi.fn(); const mockStop = vi.fn(); vi.mock("@vercel/sandbox", () => ({ @@ -11,6 +12,7 @@ vi.mock("@vercel/sandbox", () => ({ status: "running", runCommand: mockRunCommand, readFileToBuffer: mockReadFileToBuffer, + writeFiles: mockWriteFiles, stop: mockStop, })), }, @@ -21,7 +23,16 @@ vi.mock("./credentials.js", () => ({ getSandboxCredentials: () => ({}), })); -import { checkAgentDone, collectAgentResults, teardownSandbox } from "./poll-agent.js"; +vi.mock("../../env.js", () => ({ + env: { + GITHUB_TOKEN: "ghp_test_token", + GITHUB_OWNER: "test-owner", + GITHUB_REPO: "test-repo", + CLAUDE_MODEL: "claude-sonnet-4-20250514", + }, +})); + +import { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } from "./poll-agent.js"; describe("checkAgentDone", () => { beforeEach(() => vi.clearAllMocks()); @@ -53,21 +64,20 @@ describe("checkAgentDone", () => { }); }); -describe("collectAgentResults", () => { +describe("collectAgentOutput", () => { beforeEach(() => vi.clearAllMocks()); it("returns failure when sandbox is unreachable", async () => { const { Sandbox } = await import("@vercel/sandbox"); (Sandbox.get as ReturnType).mockRejectedValueOnce(new Error("gone")); - const result = await collectAgentResults("sbx-test-123"); + const result = await collectAgentOutput("sbx-test-123"); expect(result.output.result).toBe("failed"); expect(result.output.error).toContain("unreachable"); - expect(result.files).toHaveLength(0); }); - it("reads stdout, stderr and extracts changed files", async () => { + it("reads stdout and stderr and parses agent output", async () => { const mockStdout = vi.fn(); mockRunCommand.mockImplementation(() => ({ exitCode: 0, @@ -76,18 +86,162 @@ describe("collectAgentResults", () => { mockStdout .mockResolvedValueOnce(JSON.stringify({ result: "implemented", summary: "Done" })) // stdout - .mockResolvedValueOnce("") // stderr - .mockResolvedValueOnce("abc123") // pre-agent sha - .mockResolvedValueOnce("src/index.ts"); // git diff --name-only + .mockResolvedValueOnce(""); // stderr - mockReadFileToBuffer.mockResolvedValue(Buffer.from("console.log('hello')")); - - const result = await collectAgentResults("sbx-test-123"); + const result = await collectAgentOutput("sbx-test-123"); expect(result.output.result).toBe("implemented"); - expect(result.files).toHaveLength(1); - expect(result.files[0].path).toBe("src/index.ts"); - expect(result.files[0].content).toBe("console.log('hello')"); + }); +}); + +describe("pushFromSandbox", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns error when agent made no commits", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation(() => ({ + exitCode: 0, + stdout: mockStdout, + })); + + // baseSha and headSha are the same + mockStdout + .mockResolvedValueOnce("abc123") // cat /tmp/.pre-agent-sha + .mockResolvedValueOnce("abc123"); // git rev-parse HEAD + + const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); + + expect(result.pushed).toBe(false); + expect(result.error).toContain("no commits"); + }); + + it("pushes successfully when agent made commits", async () => { + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation((..._args: unknown[]) => { + const i = callIndex.value++; + if (i === 0) { + // cat /tmp/.pre-agent-sha + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("abc123") }; + } else if (i === 1) { + // git rev-parse HEAD + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; + } else if (i === 2) { + // git remote set-url + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else { + // git push + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(""), stderr: vi.fn().mockResolvedValue("") }; + } + }); + + const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); + + expect(result.pushed).toBe(true); + // Verify git push was called with args array (no shell injection) + expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "origin", "HEAD:refs/heads/blazebot/task-1"]); + }); + + it("returns error when push fails", async () => { + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation(() => { + const i = callIndex.value++; + if (i === 0) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("abc123") }; + } else if (i === 1) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; + } else if (i === 2) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else { + // git push fails + return { + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("pre-push hook declined"), + }; + } + }); + + const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); + + expect(result.pushed).toBe(false); + expect(result.error).toBe("pre-push hook declined"); + }); + + it("pushes anyway when sentinel file is missing", async () => { + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation(() => { + const i = callIndex.value++; + if (i === 0) { + // cat /tmp/.pre-agent-sha — missing, returns empty + return { exitCode: 1, stdout: vi.fn().mockResolvedValue("") }; + } else if (i === 1) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; + } else if (i === 2) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(""), stderr: vi.fn().mockResolvedValue("") }; + } + }); + + const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); + + expect(result.pushed).toBe(true); + }); +}); + +describe("fixAndRetryPush", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes prompt to file and retries push successfully", async () => { + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation(() => { + const i = callIndex.value++; + if (i === 0) { + // claude fix agent + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else if (i === 1) { + // cat /tmp/fix-stdout.txt + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("Fixed lint errors") }; + } else { + // git push retry + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(""), stderr: vi.fn().mockResolvedValue("") }; + } + }); + mockWriteFiles.mockResolvedValue(undefined); + + const result = await fixAndRetryPush("sbx-test-123", "blazebot/task-1", "lint failed"); + + expect(result.pushed).toBe(true); + // Verify prompt was written to file (not echoed into shell) + expect(mockWriteFiles).toHaveBeenCalledWith([ + expect.objectContaining({ path: "/tmp/fix-prompt.txt" }), + ]); + // Verify push uses args array + expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "origin", "HEAD:refs/heads/blazebot/task-1"]); + }); + + it("returns error when retry push also fails", async () => { + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation(() => { + const i = callIndex.value++; + if (i === 0) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else if (i === 1) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; + } else { + return { + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("still failing"), + }; + } + }); + mockWriteFiles.mockResolvedValue(undefined); + + const result = await fixAndRetryPush("sbx-test-123", "blazebot/task-1", "lint failed"); + + expect(result.pushed).toBe(false); + expect(result.error).toBe("still failing"); }); }); diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 941590a..1416e48 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -30,12 +30,13 @@ export async function checkAgentDone( } /** - * Reconnects to the sandbox, reads agent stdout/stderr, extracts changed files, - * and returns the parsed result. + * Reconnects to the sandbox, reads agent stdout/stderr, and returns the + * parsed result. File extraction is no longer needed — commits are pushed + * directly from the sandbox via `pushFromSandbox`. */ -export async function collectAgentResults( +export async function collectAgentOutput( sandboxId: string, -): Promise<{ output: AgentOutput; files: Array<{ path: string; content: string }> }> { +): Promise<{ output: AgentOutput }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); @@ -46,7 +47,6 @@ export async function collectAgentResults( // Sandbox unreachable between final poll and collection — return a clear failure return { output: { result: "failed", error: "Sandbox became unreachable before results could be collected" }, - files: [], }; } @@ -60,41 +60,104 @@ export async function collectAgentResults( const raw = stdout || stderr; const output = parseAgentOutput(raw); - // Extract changed files - const baseResult = await sandbox.runCommand("bash", [ + return { output }; +} + +/** + * After the agent exits, injects the GitHub token and pushes commits to GitHub. + * The agent process is dead at this point — the token is never visible to it. + */ +export async function pushFromSandbox( + sandboxId: string, + branch: string, +): Promise<{ pushed: boolean; error?: string }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { env } = await import("../../env.js"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Check if agent made any commits. + // If the sentinel file is missing (provisioning issue), skip the check and push anyway. + const baseShaResult = await sandbox.runCommand("bash", [ + "-c", "cat /tmp/.pre-agent-sha 2>/dev/null || echo ''", + ]); + const headShaResult = await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD"]); + const baseSha = (await baseShaResult.stdout()).trim(); + const headSha = (await headShaResult.stdout()).trim(); + + if (baseSha && baseSha === headSha) { + return { pushed: false, error: "Agent reported success but made no commits" }; + } + + // Inject token — agent process is dead + const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; + await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + + // Unshallow so git can negotiate objects with GitHub during push. + // The sandbox clones with depth:1 — without full history, push fails with + // "Could not read " when the remote has commits the shallow clone can't traverse. + await sandbox.runCommand("git", ["fetch", "--unshallow", "origin"]); + + // Push to GitHub — use HEAD: so it works even if the local branch name + // doesn't match (e.g. shallow clone leaves HEAD detached). + const result = await sandbox.runCommand("git", ["push", "origin", `HEAD:refs/heads/${branch}`]); + + if (result.exitCode !== 0) { + const stdout = (await result.stdout()).trim(); + const stderr = (await result.stderr()).trim(); + return { pushed: false, error: stderr || stdout }; + } + + return { pushed: true }; +} + +/** + * If `pushFromSandbox` fails (e.g. pre-push hook failure on the real remote), + * spawns a lightweight fix agent in the same sandbox to resolve the issue, + * then retries the push once. + * + * SECURITY NOTE: The fix agent runs with the GitHub token present in + * .git/config (set by the prior `pushFromSandbox` call). This is a deliberate + * trade-off — the agent is short-lived, narrowly prompted, and the sandbox is + * torn down immediately after. + */ +export async function fixAndRetryPush( + sandboxId: string, + branch: string, + pushError: string, +): Promise<{ pushed: boolean; error?: string }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { env } = await import("../../env.js"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Write prompt to a file to avoid shell injection via pushError content + const fixPrompt = `The git push failed with this error:\n\n${pushError}\n\nFix the issues, commit your fixes, then push to origin.`; + await sandbox.writeFiles([ + { path: "/tmp/fix-prompt.txt", content: Buffer.from(fixPrompt) }, + ]); + + await sandbox.runCommand("bash", [ "-c", - "cat /tmp/.pre-agent-sha 2>/dev/null || git rev-list --max-parents=0 HEAD", + `cat /tmp/fix-prompt.txt | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, ]); - const baseSha = (await baseResult.stdout()).trim(); - - let files: Array<{ path: string; content: string }> = []; - - if (baseSha) { - const diffResult = await sandbox.runCommand("git", [ - "diff", "--name-only", baseSha, "HEAD", - ]); - const diffOutput = (await diffResult.stdout()).trim(); - - if (diffOutput) { - const filePaths = diffOutput - .split("\n") - .filter(Boolean) - .filter((p) => p !== "requirements.md") - .filter((p) => !p.startsWith(".claude/")); - - for (const filePath of filePaths) { - const buf = await sandbox.readFileToBuffer({ - path: filePath, - cwd: "/vercel/sandbox", - }); - if (buf) { - files.push({ path: filePath, content: buf.toString("utf-8") }); - } - } - } + + // Log fix agent output for observability + const fixOut = await sandbox.runCommand("cat", ["/tmp/fix-stdout.txt"]); + const fixLog = (await fixOut.stdout()).trim(); + if (fixLog) { + console.log(`[fixAndRetryPush] fix agent output: ${fixLog.slice(0, 500)}`); } - return { output, files }; + // Retry push — use HEAD: to handle detached HEAD from shallow clone + const result = await sandbox.runCommand("git", ["push", "origin", `HEAD:refs/heads/${branch}`]); + + if (result.exitCode !== 0) { + const stdout = (await result.stdout()).trim(); + const stderr = (await result.stderr()).trim(); + return { pushed: false, error: stderr || stdout }; + } + return { pushed: true }; } /** diff --git a/src/workflows/implementation.ts b/src/workflows/implementation.ts index 5cb807d..26f8500 100644 --- a/src/workflows/implementation.ts +++ b/src/workflows/implementation.ts @@ -68,17 +68,6 @@ async function provisionAndStartAgent( } provisionAndStartAgent.maxRetries = 0; -async function pushChanges( - branchName: string, - files: Array<{ path: string; content: string }>, -) { - "use step"; - if (files.length === 0) return; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { vcs } = createStepAdapters(); - await vcs.push(branchName, files); -} - async function createPullRequest( branchName: string, title: string, @@ -156,7 +145,7 @@ export async function implementationWorkflow(ticketId: string) { const requirementsMd = await assembleImplementationRequirements(ticket); // --- Detached execution with polling --- - const { checkAgentDone, collectAgentResults, teardownSandbox } = + const { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); const sandboxId = await provisionAndStartAgent(branchName, requirementsMd); @@ -184,18 +173,27 @@ export async function implementationWorkflow(ticketId: string) { } let output: AgentOutput; - let files: Array<{ path: string; content: string }>; if (agentDone) { - ({ output, files } = await collectAgentResults(sandboxId)); + ({ output } = await collectAgentOutput(sandboxId)); } else { output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; - files = []; } - await pushChanges(branchName, files); - if (output.result === "implemented") { + let pushResult = await pushFromSandbox(sandboxId, branchName); + + if (!pushResult.pushed && pushResult.error) { + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + } + + if (!pushResult.pushed) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + await createPullRequest(branchName, ticket.title, output.summary ?? ""); await moveTicket(ticketId, env.COLUMN_AI_REVIEW); await notifySlack(`Task ${ticket.identifier} PR ready for review`); diff --git a/src/workflows/review-fix.ts b/src/workflows/review-fix.ts index c4b17d6..a1d8aa4 100644 --- a/src/workflows/review-fix.ts +++ b/src/workflows/review-fix.ts @@ -18,7 +18,7 @@ async function fetchAndValidateTicket(ticketId: string, columnAi: string) { return ticket; } -async function fetchPRContext(branchName: string, baseBranch: string) { +async function fetchPRContext(branchName: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); const { vcs } = createStepAdapters(); @@ -28,12 +28,7 @@ async function fetchPRContext(branchName: string, baseBranch: string) { const comments = await vcs.getPRComments(pr.id); const hasConflicts = await vcs.getPRConflictStatus(pr.id); - let baseSha: string | undefined; - if (hasConflicts) { - baseSha = await vcs.getBranchSha(baseBranch); - } - - return { pr, comments, hasConflicts, baseSha }; + return { pr, comments, hasConflicts }; } async function assembleReviewFixRequirements( @@ -89,18 +84,6 @@ async function provisionAndStartFixingAgent( } provisionAndStartFixingAgent.maxRetries = 0; -async function pushChanges( - branchName: string, - files: Array<{ path: string; content: string }>, - mergeParentSha?: string, -) { - "use step"; - if (files.length === 0) return; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { vcs } = createStepAdapters(); - await vcs.push(branchName, files, mergeParentSha ? { mergeParentSha } : undefined); -} - async function moveTicket(ticketId: string, column: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); @@ -149,7 +132,7 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { `Task ${ticket.identifier} started — fixing review feedback`, ); - const { comments, hasConflicts, baseSha } = await fetchPRContext(branchName, env.GITHUB_BASE_BRANCH); + const { comments, hasConflicts } = await fetchPRContext(branchName); const requirementsMd = await assembleReviewFixRequirements( ticket, @@ -158,7 +141,7 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { ); // --- Detached execution with polling --- - const { checkAgentDone, collectAgentResults, teardownSandbox } = + const { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); const sandboxId = await provisionAndStartFixingAgent( @@ -189,18 +172,27 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { } let output: AgentOutput; - let files: Array<{ path: string; content: string }>; if (agentDone) { - ({ output, files } = await collectAgentResults(sandboxId)); + ({ output } = await collectAgentOutput(sandboxId)); } else { output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; - files = []; } - await pushChanges(branchName, files, baseSha); - if (output.result === "implemented") { + let pushResult = await pushFromSandbox(sandboxId, branchName); + + if (!pushResult.pushed && pushResult.error) { + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + } + + if (!pushResult.pushed) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); await notifySlack( `Task ${ticket.identifier} fixes applied, ready for re-review`, From f2a64f57aa87a48ed6b9fa8ba5c2e2953929c5ef Mon Sep 17 00:00:00 2001 From: ai-workflow-blazity Date: Thu, 2 Apr 2026 16:39:09 +0200 Subject: [PATCH 04/71] Aiw 16 cicd info (#38) * feat: add cicd check * feat: ci/cd fail check * feat: remove console log * Aiw 25 safeguard (#37) * feat: add redis safeguard * fix: json parse --------- Co-authored-by: kasin-it * feat: push inside the sandbox (#40) * feat: add cicd check * feat: ci/cd fail check * feat: remove console log * feat: remove console.log * fix: ci --------- Co-authored-by: kasin-it Co-authored-by: Kacper <89151689+kasin-it@users.noreply.github.com> --- ...eview-fix-cicd-and-line-comments-design.md | 170 ++++++++++++++++++ src/adapters/vcs/github.ts | 67 ++++++- src/adapters/vcs/types.ts | 11 ++ src/lib/dispatch.test.ts | 1 + src/lib/prompts.ts | 17 +- src/sandbox/context.test.ts | 94 +++++++++- src/sandbox/context.ts | 59 +++++- src/workflows/review-fix.ts | 24 ++- 8 files changed, 422 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-02-review-fix-cicd-and-line-comments-design.md diff --git a/docs/superpowers/specs/2026-04-02-review-fix-cicd-and-line-comments-design.md b/docs/superpowers/specs/2026-04-02-review-fix-cicd-and-line-comments-design.md new file mode 100644 index 0000000..d826485 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-review-fix-cicd-and-line-comments-design.md @@ -0,0 +1,170 @@ +# Review-Fix Flow: CI/CD Check Awareness & Structured Line Comments + +## Problem + +When the review-fix workflow runs, the agent is missing two key pieces of information: + +1. **CI/CD check results** — the agent has no visibility into whether checks (lint, build, tests, e2e) passed or failed. It cannot act on failures it doesn't know about. +2. **Line-coupled comment location** — review comments attached to specific lines lose their file path and line numbers. The agent sees flat text like `"Bob: Fix the typo"` with no indication of where in the code the comment refers to. + +## Solution + +### 1. Enrich `PRComment` with Location Fields + +Extend the existing `PRComment` interface with optional location fields. GitHub's `pulls.listReviewComments()` already returns `path`, `start_line`, and `line` — currently discarded. + +**Updated `PRComment` in `src/adapters/vcs/types.ts`:** + +```typescript +export interface PRComment { + author: string; + body: string; + liked: boolean; + filePath?: string; // only on review comments (not issue comments) + startLine?: number; // start of the comment range + endLine?: number; // end of the comment range (same as startLine for single-line) +} +``` + +**GitHub adapter mapping in `getPRComments()`:** + +Review comments map `c.path` -> `filePath`, `c.start_line` -> `startLine`, `c.line` -> `endLine`. +Issue comments have no location and remain as-is. + +**Context formatting in `formatPRComments()`:** + +Line-coupled comments render with a structured header: + +``` +### src/lib/auth.ts (lines 42-45) +Bob: Use a constant instead of a magic number + +### src/components/Form.tsx (line 12) +Alice (liked): Looks good but add error handling +``` + +General comments (no `filePath`) render as before: + +``` +Bob: Overall looks good, just a few nits +``` + +Comments are grouped: line-coupled comments first (sorted by file path), then general comments. + +### 2. CI/CD Check Results with Logs for Failures + +Add a new type and method to fetch check run results, including full log output for failed checks. + +**New type in `src/adapters/vcs/types.ts`:** + +```typescript +export interface CheckRunResult { + name: string; + status: "completed" | "in_progress" | "queued"; + conclusion: string | null; // "success", "failure", "cancelled", "timed_out", etc. + logs?: string; // full output, only populated for non-success conclusions +} +``` + +**New method on `VCSAdapter` interface:** + +```typescript +getCheckRunResults(prId: number): Promise; +``` + +**GitHub adapter implementation:** + +1. Get the PR's head SHA via `pulls.get(prId)` +2. Call `checks.listForRef({ ref: headSha })` to get all check runs +3. For each check where `conclusion !== 'success'` and status is `'completed'`: fetch logs via `actions.listJobsForWorkflowRun()` to find the matching job, then `actions.downloadJobLogsForWorkflowRun()` for the log content +4. Return all checks; only failures have `logs` populated + +**Context formatting — new `formatCheckResults()` function:** + +All checks passed: +``` +All CI/CD checks passed. +``` + +No checks found: +``` +No CI/CD checks found. +``` + +Mixed results: +``` +Passed: lint, build, type-check + +### Failed: test + + +### Failed: e2e + +``` + +### 3. Thread Data Through the Workflow + +**`FixingFeedbackContextInput` in `src/sandbox/context.ts`:** + +```typescript +export interface FixingFeedbackContextInput { + ticket: TicketData; + prompt: string; + skills?: string; + prComments: PRComment[]; + hasConflicts: boolean; + checkResults: CheckRunResult[]; +} +``` + +**`assembleFixingFeedbackContext()` adds a new section** between "PR Review Feedback" and "Merge Conflicts": + +``` +## CI/CD Check Results + + +``` + +**`fetchPRContext()` in `src/workflows/review-fix.ts`:** + +Add `vcs.getCheckRunResults(pr.id)` call alongside existing comment and conflict fetches. Return `checkResults` in the result object. + +**`assembleReviewFixRequirements()`** passes `checkResults` through to `assembleFixingFeedbackContext()`. + +### 4. Prompt Update + +In the review-fix prompt (`src/lib/prompts.ts`), add a new step after merge conflict resolution and before addressing review comments: + +``` +3. If CI/CD checks failed, read the failure logs in "CI/CD Check Results" and fix the underlying issues (test failures, lint errors, build errors, etc.). +``` + +Update the constraints section to acknowledge CI failures as actionable: + +``` +- Address CI/CD check failures in addition to review comments. +``` + +## Files Changed + +| File | Change | +|------|--------| +| `src/adapters/vcs/types.ts` | Add fields to `PRComment`, add `CheckRunResult`, add `getCheckRunResults` to `VCSAdapter` | +| `src/adapters/vcs/github.ts` | Map location fields in `getPRComments()`, implement `getCheckRunResults()` | +| `src/sandbox/context.ts` | Update `FixingFeedbackContextInput`, add `formatCheckResults()`, update `formatPRComments()` grouping, add CI/CD section to template | +| `src/workflows/review-fix.ts` | Fetch check results in `fetchPRContext()`, pass to context assembly | +| `src/lib/prompts.ts` | Add CI/CD step to review-fix prompt | +| `src/sandbox/context.test.ts` | Update tests for new fields and formatting | + +## Edge Cases + +- **Non-GitHub-Actions checks** (external CI like CircleCI, Jenkins): `checks.listForRef()` returns the check run but `actions.downloadJobLogsForWorkflowRun()` won't work. For these, populate `logs` as `null` and show the check name + conclusion without logs. +- **Very large logs**: GitHub Actions logs can be large. No truncation — the agent needs the full output to diagnose failures. If this becomes a token problem in practice, we can add truncation later. +- **No check runs at all**: Some repos may not have CI configured. Show "No CI/CD checks found." and proceed normally. + +## Out of Scope + +- Fetching logs for in-progress or queued checks (only completed failures) +- Re-running failed checks from the agent +- Fetching review approval/request status +- Threaded comment conversations (parent/reply chains) diff --git a/src/adapters/vcs/github.ts b/src/adapters/vcs/github.ts index 35240e6..9221caf 100644 --- a/src/adapters/vcs/github.ts +++ b/src/adapters/vcs/github.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest"; import { FatalError } from "workflow"; -import type { VCSAdapter, PullRequest, PRComment } from "./types.js"; +import type { VCSAdapter, PullRequest, PRComment, CheckRunResult } from "./types.js"; export interface GitHubConfig { token: string; @@ -174,6 +174,9 @@ export class GitHubAdapter implements VCSAdapter { author: c.user?.login ?? "unknown", body: c.body ?? "", liked: (c.reactions?.total_count ?? 0) > 0, + filePath: c.path, + startLine: c.start_line ?? c.line, + endLine: c.line, })), ...issueComments.map((c) => ({ author: c.user?.login ?? "unknown", @@ -184,6 +187,68 @@ export class GitHubAdapter implements VCSAdapter { return comments; } + async getCheckRunResults(prId: number): Promise { + const { data: pr } = await this.octokit.pulls.get({ + ...this.ownerRepo, + pull_number: prId, + }); + const headSha = pr.head.sha; + + const { data: checksData } = await this.octokit.checks.listForRef({ + ...this.ownerRepo, + ref: headSha, + }); + + const results: CheckRunResult[] = []; + for (const check of checksData.check_runs) { + const entry: CheckRunResult = { + name: check.name, + status: check.status as CheckRunResult["status"], + conclusion: check.conclusion ?? null, + }; + + if ( + check.status === "completed" && + check.conclusion !== "success" && + check.conclusion !== null + ) { + try { + // Find the matching workflow job and fetch its logs + const runs = + await this.octokit.actions.listWorkflowRunsForRepo({ + ...this.ownerRepo, + head_sha: headSha, + }); + + for (const run of runs.data.workflow_runs) { + const { data: jobs } = + await this.octokit.actions.listJobsForWorkflowRun({ + ...this.ownerRepo, + run_id: run.id, + }); + + const matchingJob = jobs.jobs.find((j) => j.name === check.name); + if (matchingJob) { + const { data: logData } = + await this.octokit.actions.downloadJobLogsForWorkflowRun({ + ...this.ownerRepo, + job_id: matchingJob.id, + }); + entry.logs = String(logData); + break; + } + } + } catch { + // Non-GitHub-Actions checks (CircleCI, Jenkins, etc.) won't have logs + } + } + + results.push(entry); + } + + return results; + } + async getPRConflictStatus(prId: number): Promise { const { data } = await this.octokit.pulls.get({ ...this.ownerRepo, diff --git a/src/adapters/vcs/types.ts b/src/adapters/vcs/types.ts index 83240de..32e223f 100644 --- a/src/adapters/vcs/types.ts +++ b/src/adapters/vcs/types.ts @@ -8,6 +8,16 @@ export interface PRComment { author: string; body: string; liked: boolean; + filePath?: string; + startLine?: number; + endLine?: number; +} + +export interface CheckRunResult { + name: string; + status: "completed" | "in_progress" | "queued"; + conclusion: string | null; + logs?: string; } export interface VCSAdapter { @@ -19,6 +29,7 @@ export interface VCSAdapter { options?: { mergeParentSha?: string }, ): Promise; getPRComments(prId: number): Promise; + getCheckRunResults(prId: number): Promise; getPRConflictStatus(prId: number): Promise; findPR(branch: string): Promise; getBranchSha(branch: string): Promise; diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 61f9b06..c3e7e8d 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -64,6 +64,7 @@ function makeAdapters( createPR: vi.fn(), push: vi.fn(), getPRComments: vi.fn(), + getCheckRunResults: vi.fn().mockResolvedValue([]), getPRConflictStatus: vi.fn(), findPR: overrides.findPR ?? vi.fn().mockResolvedValue(null), getBranchSha: vi.fn().mockResolvedValue("abc123"), diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 11157ce..9bb5dd6 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -150,6 +150,7 @@ You have access to **superpowers skills** installed globally. Use them to improv ## Constraints - Only address the specific review comments listed in PR Review Feedback. +- Address CI/CD check failures in addition to review comments. - Do not refactor code outside the scope of the feedback. - Do not make changes beyond what reviewers requested. - Follow existing code conventions in the repository (check CLAUDE.md, AGENTS.md if present). @@ -159,14 +160,14 @@ You have access to **superpowers skills** installed globally. Use them to improv 0. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. Use the progress, decisions, and file list to understand prior implementation context and any previous fix attempts. 1. Read the review feedback carefully. 2. If merge conflicts exist, the base branch has already been merged into your branch — the repo is in a \`MERGING\` state with conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) in the affected files. Do NOT run \`git merge\` again. Instead: edit each conflicted file to resolve the markers, then \`git add\` the resolved files, then run \`git merge --continue\` to complete the merge. -3. Address each review comment — implement the requested changes. -4. Run all tests to ensure nothing is broken. -5. Self-review your changes. -6. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. -7. **Update session memory** — before returning your result, write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -8. Commit your work with descriptive commit messages that explain the "why", not just - the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -9. Run all quality checks and push (see Quality Gate below). +3. If CI/CD checks failed, read the failure logs in "CI/CD Check Results" and fix the underlying issues (test failures, lint errors, build errors, etc.). +4. Address each review comment — implement the requested changes. +5. Run all tests to ensure nothing is broken. +6. Self-review your changes. +7. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. +8. **Update session memory** — before returning your result, write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). +9. Commit your work with descriptive commit messages that explain the "why", not just the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). +10. Run all quality checks and push (see Quality Gate below). ## Comment Overrides diff --git a/src/sandbox/context.test.ts b/src/sandbox/context.test.ts index 79643f8..22aef52 100644 --- a/src/sandbox/context.test.ts +++ b/src/sandbox/context.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { assembleImplementationContext, assembleFixingFeedbackContext } from "./context.js"; +import { assembleImplementationContext, assembleFixingFeedbackContext, formatCheckResults } from "./context.js"; describe("assembleImplementationContext", () => { it("assembles requirements.md for implementation", () => { @@ -43,6 +43,7 @@ describe("assembleFixingFeedbackContext", () => { { author: "Bob", body: "Fix the typo on line 5", liked: true }, ], hasConflicts: true, + checkResults: [], }); expect(result).toContain("# Requirements"); @@ -51,7 +52,98 @@ describe("assembleFixingFeedbackContext", () => { expect(result.indexOf("## Ticket ID")).toBeLessThan(result.indexOf("## Ticket\n")); expect(result).toContain("## PR Review Feedback"); expect(result).toContain("Fix the typo on line 5"); + expect(result).toContain("## CI/CD Check Results"); expect(result).toContain("## Merge Conflicts"); expect(result).toContain("You are a review-fix agent..."); }); + + it("renders line-coupled comments with file path and line range", () => { + const result = assembleFixingFeedbackContext({ + ticket: { + identifier: "TEST-3", + title: "Fix auth", + description: "Fix auth module", + acceptanceCriteria: "", + comments: [], + }, + prompt: "prompt", + prComments: [ + { author: "Bob", body: "Use a constant", liked: false, filePath: "src/lib/auth.ts", startLine: 42, endLine: 45 }, + { author: "Alice", body: "Looks good but add error handling", liked: true, filePath: "src/components/Form.tsx", startLine: 12, endLine: 12 }, + { author: "Charlie", body: "Overall looks good", liked: false }, + ], + hasConflicts: false, + checkResults: [], + }); + + expect(result).toContain("### src/lib/auth.ts (lines 42-45)"); + expect(result).toContain("Bob: Use a constant"); + expect(result).toContain("### src/components/Form.tsx (line 12)"); + expect(result).toContain("Alice (liked): Looks good but add error handling"); + expect(result).toContain("Charlie: Overall looks good"); + // Line-coupled comments should appear before general comments + expect(result.indexOf("src/components/Form.tsx")).toBeLessThan(result.indexOf("Charlie: Overall looks good")); + }); + + it("includes CI/CD check results section between PR feedback and merge conflicts", () => { + const result = assembleFixingFeedbackContext({ + ticket: { + identifier: "TEST-4", + title: "Fix tests", + description: "Fix failing tests", + acceptanceCriteria: "", + comments: [], + }, + prompt: "prompt", + prComments: [], + hasConflicts: false, + checkResults: [ + { name: "lint", status: "completed", conclusion: "success" }, + { name: "test", status: "completed", conclusion: "failure", logs: "FAIL src/app.test.ts\nExpected true, got false" }, + ], + }); + + const prFeedbackIdx = result.indexOf("## PR Review Feedback"); + const ciIdx = result.indexOf("## CI/CD Check Results"); + const mergeIdx = result.indexOf("## Merge Conflicts"); + expect(ciIdx).toBeGreaterThan(prFeedbackIdx); + expect(ciIdx).toBeLessThan(mergeIdx); + expect(result).toContain("Passed: lint"); + expect(result).toContain("### Failed: test"); + expect(result).toContain("FAIL src/app.test.ts"); + }); +}); + +describe("formatCheckResults", () => { + it("returns message when no checks found", () => { + expect(formatCheckResults([])).toBe("No CI/CD checks found."); + }); + + it("returns all-passed message when all succeed", () => { + const result = formatCheckResults([ + { name: "lint", status: "completed", conclusion: "success" }, + { name: "build", status: "completed", conclusion: "success" }, + ]); + expect(result).toBe("All CI/CD checks passed."); + }); + + it("shows passed and failed checks with logs", () => { + const result = formatCheckResults([ + { name: "lint", status: "completed", conclusion: "success" }, + { name: "build", status: "completed", conclusion: "success" }, + { name: "test", status: "completed", conclusion: "failure", logs: "Error: test failed" }, + { name: "e2e", status: "completed", conclusion: "failure", logs: "Timeout on login page" }, + ]); + expect(result).toContain("Passed: lint, build"); + expect(result).toContain("### Failed: test\nError: test failed"); + expect(result).toContain("### Failed: e2e\nTimeout on login page"); + }); + + it("shows conclusion when logs are not available", () => { + const result = formatCheckResults([ + { name: "external-ci", status: "completed", conclusion: "failure" }, + ]); + expect(result).toContain("### Failed: external-ci"); + expect(result).toContain("Conclusion: failure"); + }); }); diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 1f01eb4..b1c6334 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,4 +1,4 @@ -import type { PRComment } from "../adapters/vcs/types.js"; +import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; interface TicketData { identifier: string; @@ -20,6 +20,7 @@ export interface FixingFeedbackContextInput { skills?: string; prComments: PRComment[]; hasConflicts: boolean; + checkResults: CheckRunResult[]; } export function assembleImplementationContext( @@ -58,7 +59,7 @@ ${prompt} export function assembleFixingFeedbackContext( input: FixingFeedbackContextInput, ): string { - const { ticket, prompt, prComments, hasConflicts } = input; + const { ticket, prompt, prComments, hasConflicts, checkResults } = input; return `# Requirements @@ -86,6 +87,10 @@ ${formatComments(ticket.comments)} ${formatPRComments(prComments)} +## CI/CD Check Results + +${formatCheckResults(checkResults)} + ## Merge Conflicts ${hasConflicts ? "This PR has merge conflicts. The base branch has already been merged — the repo is in a MERGING state with conflict markers in the affected files. Resolve the markers, `git add` the files, and run `git merge --continue`." : "No merge conflicts."} @@ -107,7 +112,51 @@ function formatComments( function formatPRComments(comments: PRComment[]): string { if (comments.length === 0) return "No review feedback."; - return comments - .map((c) => `${c.author}${c.liked ? " (liked)" : ""}: ${c.body}`) - .join("\n\n"); + + const lineCoupled = comments + .filter((c) => c.filePath) + .sort((a, b) => (a.filePath! < b.filePath! ? -1 : a.filePath! > b.filePath! ? 1 : 0)); + const general = comments.filter((c) => !c.filePath); + + const parts: string[] = []; + + for (const c of lineCoupled) { + const lineRange = + c.startLine && c.endLine && c.startLine !== c.endLine + ? `lines ${c.startLine}-${c.endLine}` + : `line ${c.endLine ?? c.startLine}`; + parts.push( + `### ${c.filePath} (${lineRange})\n${c.author}${c.liked ? " (liked)" : ""}: ${c.body}`, + ); + } + + for (const c of general) { + parts.push(`${c.author}${c.liked ? " (liked)" : ""}: ${c.body}`); + } + + return parts.join("\n\n"); +} + +export function formatCheckResults(checks: CheckRunResult[]): string { + if (checks.length === 0) return "No CI/CD checks found."; + + const passed = checks.filter( + (c) => c.status === "completed" && c.conclusion === "success", + ); + const failed = checks.filter( + (c) => c.status === "completed" && c.conclusion !== "success" && c.conclusion !== null, + ); + + if (failed.length === 0) return "All CI/CD checks passed."; + + const parts: string[] = []; + if (passed.length > 0) { + parts.push(`Passed: ${passed.map((c) => c.name).join(", ")}`); + } + + for (const c of failed) { + parts.push(`### Failed: ${c.name}\n${c.logs ?? `Conclusion: ${c.conclusion}`}`); + } + + return parts.join("\n\n"); } diff --git a/src/workflows/review-fix.ts b/src/workflows/review-fix.ts index a1d8aa4..c27ce6b 100644 --- a/src/workflows/review-fix.ts +++ b/src/workflows/review-fix.ts @@ -2,7 +2,7 @@ import { FatalError } from "workflow"; import { sleep } from "workflow"; import type { AgentOutput } from "../sandbox/agent-runner.js"; import type { TicketContent } from "../adapters/issue-tracker/types.js"; -import type { PRComment } from "../adapters/vcs/types.js"; +import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; // --- Step Functions --- @@ -27,14 +27,16 @@ async function fetchPRContext(branchName: string) { const comments = await vcs.getPRComments(pr.id); const hasConflicts = await vcs.getPRConflictStatus(pr.id); + const checkResults = await vcs.getCheckRunResults(pr.id); - return { pr, comments, hasConflicts }; + return { pr, comments, hasConflicts, checkResults }; } async function assembleReviewFixRequirements( ticket: TicketContent, prComments: PRComment[], hasConflicts: boolean, + checkResults: CheckRunResult[], ) { "use step"; const { assembleFixingFeedbackContext } = @@ -53,6 +55,7 @@ async function assembleReviewFixRequirements( prompt, prComments, hasConflicts, + checkResults, }); } @@ -78,7 +81,11 @@ async function provisionAndStartFixingAgent( jobTimeoutMs: env.JOB_TIMEOUT_MS, }); - const sandbox = await manager.provision(branchName, requirementsMd, mergeBase); + const sandbox = await manager.provision( + branchName, + requirementsMd, + mergeBase, + ); await startAgentDetached(sandbox); return sandbox.sandboxId; } @@ -132,12 +139,13 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { `Task ${ticket.identifier} started — fixing review feedback`, ); - const { comments, hasConflicts } = await fetchPRContext(branchName); + const { comments, hasConflicts, checkResults } = await fetchPRContext(branchName); const requirementsMd = await assembleReviewFixRequirements( ticket, comments, hasConflicts, + checkResults, ); // --- Detached execution with polling --- @@ -211,8 +219,12 @@ export async function reviewFixWorkflow(ticketId: string, branchName: string) { } } catch (err) { console.error(`Workflow failed for ${ticket.identifier}:`, err); - const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); - await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG) + .then(() => true) + .catch(() => false); + await notifySlack( + `Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`, + ).catch(() => {}); if (moved) { await unregisterRun(ticket.identifier).catch(() => {}); } else { From 28d40b223bf0afbc529339f9e7b2fc6ad536af76 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 2 Apr 2026 18:00:23 +0200 Subject: [PATCH 05/71] fix: push issue --- .../specs/2026-04-02-sandbox-push-design.md | 271 ++++++++---------- src/lib/prompts.ts | 12 +- src/sandbox/manager.ts | 22 +- src/sandbox/poll-agent.test.ts | 9 +- src/sandbox/poll-agent.ts | 32 ++- 5 files changed, 161 insertions(+), 185 deletions(-) diff --git a/docs/superpowers/specs/2026-04-02-sandbox-push-design.md b/docs/superpowers/specs/2026-04-02-sandbox-push-design.md index d3b3620..257ec97 100644 --- a/docs/superpowers/specs/2026-04-02-sandbox-push-design.md +++ b/docs/superpowers/specs/2026-04-02-sandbox-push-design.md @@ -1,31 +1,39 @@ # Sandbox Push — Push from Sandbox with Real Commit Messages **Date:** 2026-04-02 -**Status:** Draft +**Status:** Draft (v2 — full clone approach) ## Problem -Today, the agent commits inside the sandbox with real commit messages, but the server -throws them away — it extracts files via `git diff`, then recreates a single commit via -the GitHub API with a hardcoded `"feat: agent implementation"` message. This means: +The original GitHub API push flow (blob → tree → commit → updateRef) lost all agent commit +messages and created a single flat commit. We replaced it with sandbox-side `git push`, but +shallow clones (`depth: 1`) cause "no history in common with main" errors on PR creation — +the unshallow + remote-swap flow is fragile and hard to debug. -1. **All agent commit messages are lost.** The PR always shows one flat commit. -2. **Pre-push hooks never run.** The GitHub API bypasses git hooks entirely. -3. **The GitHub token lives in the sandbox** as part of the clone URL in `.git/config`, - potentially visible to the agent. +Root cause: shallow clones combined with remote removal/re-addition create edge cases where +git's object graph becomes disconnected. GitHub then rejects the PR because the branch +commits don't share ancestry with main. ## Solution -Push from inside the sandbox after the agent exits. The agent commits and pushes to a -local mock remote (triggering pre-push hooks naturally). After the agent process is dead, -the server injects the GitHub token and does the real `git push` to GitHub. +**Full clone, no bare repo, agent commits only, server pushes.** + +1. Remove `depth: 1` — clone the full repo. The ~10-30s overhead is negligible vs. the + agent's 5-35 min execution time. +2. Strip auth from origin URL (instead of swapping to a local bare repo). The agent can + see the remote URL but can't push without a token. +3. Agent only commits — remove push from the Quality Gate prompt. +4. After the agent exits, server injects the token and pushes to GitHub. + +This eliminates all shallow-clone edge cases, the unshallow step, the local bare repo, +and the commit chain verification — ~40 lines of complexity that existed solely to work +around depth-1 limitations. ## Detailed Flow ### 1. Branch Creation (unchanged — server-side) The server creates the branch via GitHub API before the sandbox is provisioned. -This requires the token and stays on the server. ``` Server: vcs.createBranch("blazebot/awt-123", "main") // Octokit API @@ -36,34 +44,45 @@ Server: vcs.createBranch("blazebot/awt-123", "main") // Octokit API ### 2. Sandbox Provisioning (modified) -After `Sandbox.create` clones the repo, immediately sanitize the remote and set up -a local push target. This ensures the agent never sees the GitHub token. +Remove `depth: 1` from the clone. After clone, strip the token from the origin URL +so the agent never has push access. -```bash -# Remove origin (may contain token from clone) -git remote remove origin +```typescript +// src/sandbox/manager.ts — Sandbox.create source +source: { + type: "git", + url: `https://github.com/${owner}/${repo}.git`, + username: "x-access-token", + password: githubToken, + revision: branch, + // No depth — full clone +}, +``` -# Create local bare repo as push target -git init --bare /tmp/push-target.git -git remote add origin /tmp/push-target.git +```bash +# After clone: strip auth from origin, replace with unauthenticated URL +git remote set-url origin https://github.com//.git ``` -**File:** `src/sandbox/manager.ts` (after line 62, before git config) -**Change:** Add remote sanitization + local bare repo setup after sandbox creation. +**File:** `src/sandbox/manager.ts` +**Changes:** +- Remove `depth: 1` from `Sandbox.create` source config. +- Replace the 3-command bare-repo setup with a single `git remote set-url` to strip auth. -### 3. Git Identity + Optional Merge (unchanged) +### 3. Git Identity + Optional Merge (simplified) ```bash git config user.name "ai-workflow-blazity" git config user.email "ai-workflow@blazity.com" ``` -For review-fix workflow, the merge fetch still uses an authenticated URL passed as a -CLI argument to `git fetch`. This appears briefly in process args but is not stored. -No change needed — the agent process hasn't started yet. +For review-fix workflow, the merge fetch uses an authenticated URL passed as a CLI +argument. With a full clone, we no longer need `--unshallow` during the merge fetch — +just a normal `git fetch `. -**File:** `src/sandbox/manager.ts:64-91` -**Change:** None. +**File:** `src/sandbox/manager.ts` +**Change:** Remove `--unshallow` from the merge fetch command (no longer needed with +full clone). Use plain `git fetch "" `. ### 4. Pre-Agent SHA Recording (unchanged) @@ -71,15 +90,14 @@ No change needed — the agent process hasn't started yet. git rev-parse HEAD > /tmp/.pre-agent-sha ``` -**File:** `src/sandbox/manager.ts:93-98` +**File:** `src/sandbox/manager.ts` **Change:** None. ### 5. Agent Execution (modified prompt) -The implementation and review-fix prompts gain a quality gate instruction and a push -instruction. The agent now pushes to `origin` (which points to the local bare repo). +The Quality Gate no longer includes push instructions. The agent commits only. -**Prompt additions (both `implement.md` and `review-fix.md`):** +**Quality Gate (both prompts):** ``` ## Quality Gate @@ -88,24 +106,9 @@ Before finishing, you MUST: - Find and run ALL quality checks in the project: tests, linting, type checking, formatting, and any other validation scripts. - Fix all failures and commit your fixes with descriptive messages. -- Push your work to origin (`git push origin `). - - If the push fails due to pre-push hooks, fix the issues, commit, and push again. - - If the push succeeds, you are clear to finish. -``` - -**Prompt modification for commit messages:** - -Replace the existing line: -``` -10. Commit your work with descriptive commit messages. ``` -With: -``` -10. Commit your work with descriptive commit messages that explain the "why", not just - the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -11. Run all quality checks and push (see Quality Gate above). -``` +The push instruction (`git push origin `) is removed. The agent should NOT push. **Files:** `src/lib/prompts.ts` (both `implementPrompt` and `reviewFixPrompt`) @@ -120,42 +123,19 @@ git commit -m "fix: handle duplicate email edge case" git commit -m "test: add registration tests" ``` -Then pushes: - -``` -git push origin blazebot/awt-123 - → .husky/pre-push fires (if present) → runs lint/test - → If hook fails → agent fixes, commits, pushes again - → If hook passes → push succeeds to /tmp/push-target.git -``` - -The agent exits, wrapper script touches `/tmp/agent-done`. - -### 7. Collect Agent Output (simplified) +No push. The agent exits, wrapper script touches `/tmp/agent-done`. -Only read the agent's JSON output. **Remove file extraction entirely** — no more -`git diff` + `readFileToBuffer` loop. - -**Before:** -```typescript -// poll-agent.ts collectAgentResults() -// Reads stdout, parses JSON, extracts files via git diff, reads each file content -``` +### 7. Collect Agent Output (unchanged from v1) -**After:** -```typescript -// poll-agent.ts collectAgentOutput() -// Reads stdout, parses JSON — that's it -// Returns: { output: AgentOutput } (no files array) -``` +Reads agent stdout/stderr and parses JSON. No file extraction. -**File:** `src/sandbox/poll-agent.ts:36-98` -**Change:** Remove lines 63-94 (file extraction). Rename to `collectAgentOutput`. -Return type changes from `{ output, files }` to `{ output }`. +**File:** `src/sandbox/poll-agent.ts` — `collectAgentOutput()` +**Change:** None (already simplified in v1). -### 8. Push from Sandbox (new step) +### 8. Push from Sandbox (simplified) After the agent exits and output is collected, inject the token and push. +No unshallow needed — the full clone has complete history. ```typescript async function pushFromSandbox( @@ -168,39 +148,49 @@ async function pushFromSandbox( const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); // Check if agent made any commits - const baseSha = await sandbox.runCommand("bash", [ - "-c", "cat /tmp/.pre-agent-sha", + const baseShaResult = await sandbox.runCommand("bash", [ + "-c", "cat /tmp/.pre-agent-sha 2>/dev/null || echo ''", ]); - const headSha = await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD"]); + const headShaResult = await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD"]); + const baseSha = (await baseShaResult.stdout()).trim(); + const headSha = (await headShaResult.stdout()).trim(); - if ((await baseSha.stdout()).trim() === (await headSha.stdout()).trim()) { - return { pushed: false }; // No commits to push + if (baseSha && baseSha === headSha) { + return { pushed: false, error: "Agent reported success but made no commits" }; } // Inject token — agent process is dead const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); - // Push to GitHub - const result = await sandbox.runCommand("bash", [ - "-c", `git push origin ${branch} 2>&1`, + // Push to GitHub — use HEAD: so it works even if the local branch name + // doesn't match. Use --force-with-lease so retries on an existing branch + // succeed without risking concurrent-push data loss. + const result = await sandbox.runCommand("git", [ + "push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`, ]); if (result.exitCode !== 0) { - const error = (await result.stdout()).trim(); - return { pushed: false, error }; + const stdout = (await result.stdout()).trim(); + const stderr = (await result.stderr()).trim(); + return { pushed: false, error: stderr || stdout }; } return { pushed: true }; } ``` -**File:** New function in `src/sandbox/poll-agent.ts` (or new file `src/sandbox/push.ts`) +**What's removed vs. v1:** +- Shallow check (`git rev-parse --is-shallow-repository`) +- Unshallow step (`git fetch --unshallow origin`) +- Fallback fetch (`git fetch origin`) +- Commit chain verification (`git rev-list --count HEAD`) + +**File:** `src/sandbox/poll-agent.ts` -### 9. Fix Agent on Push Failure (new step) +### 9. Fix Agent on Push Failure (unchanged from v1) -If `pushFromSandbox` fails (e.g., pre-push hook failure), spawn a lightweight fix -agent in the same sandbox to resolve the issue. +If `pushFromSandbox` fails, spawn a lightweight fix agent in the same sandbox. ```typescript async function fixAndRetryPush( @@ -210,55 +200,51 @@ async function fixAndRetryPush( ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); + const { env } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - // Run a quick fix agent with the error context + // Write prompt to a file to avoid shell injection via pushError content const fixPrompt = `The git push failed with this error:\n\n${pushError}\n\nFix the issues, commit your fixes, then push to origin.`; + await sandbox.writeFiles([ + { path: "/tmp/fix-prompt.txt", content: Buffer.from(fixPrompt) }, + ]); + await sandbox.runCommand("bash", [ "-c", - `echo '${fixPrompt.replace(/'/g, "'\\''")}' | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, + `cat /tmp/fix-prompt.txt | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, ]); // Retry push (token is still in remote URL from previous step) - const result = await sandbox.runCommand("bash", [ - "-c", `git push origin ${branch} 2>&1`, + const result = await sandbox.runCommand("git", [ + "push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`, ]); if (result.exitCode !== 0) { - return { pushed: false, error: (await result.stdout()).trim() }; + const stdout = (await result.stdout()).trim(); + const stderr = (await result.stderr()).trim(); + return { pushed: false, error: stderr || stdout }; } return { pushed: true }; } ``` -**File:** New function alongside `pushFromSandbox`. - -### 10. Workflow Integration +**File:** `src/sandbox/poll-agent.ts` -**Implementation workflow** (`src/workflows/implementation.ts`): +### 10. Workflow Integration (unchanged from v1) -Replace: -```typescript -// Old -const { output, files } = await collectAgentResults(sandboxId); -await pushChanges(branchName, files); -``` +Both workflows use the same pattern: -With: ```typescript -// New const { output } = await collectAgentOutput(sandboxId); if (output.result === "implemented") { let pushResult = await pushFromSandbox(sandboxId, branchName); if (!pushResult.pushed && pushResult.error) { - // Pre-push hook or other failure — try fix agent pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); } if (!pushResult.pushed) { - // Push failed even after fix attempt await moveTicket(ticketId, env.COLUMN_BACKLOG); await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); await unregisterRun(ticket.identifier); @@ -270,65 +256,58 @@ if (output.result === "implemented") { } ``` -**Review-fix workflow** (`src/workflows/review-fix.ts`): - -Same pattern. Replace `collectAgentResults` + `pushChanges` with -`collectAgentOutput` + `pushFromSandbox` + `fixAndRetryPush`. - -**Note on merge commits:** The current review-fix flow uses `mergeParentSha` to create -a merge commit via the GitHub API. With sandbox push, this is handled naturally — the -sandbox already has the merge commit from step 3 (optional merge). When we `git push`, -the merge commit is included in the push. The `mergeParentSha` parameter on -`github.ts:push()` is no longer needed. +**Files:** `src/workflows/implementation.ts`, `src/workflows/review-fix.ts` +**Change:** None (already using this pattern). ### 11. PR Creation, Ticket Update, Teardown (unchanged) -PR creation still uses Octokit API (no token needed in sandbox for this). -Ticket moves and Slack notifications are unchanged. +PR creation still uses Octokit API. Ticket moves and Slack notifications unchanged. ## Files Changed | File | Change | |------|--------| -| `src/sandbox/manager.ts` | Add remote sanitization + local bare repo setup after clone | -| `src/lib/prompts.ts` | Add quality gate + push instructions to both prompts | -| `src/sandbox/poll-agent.ts` | Simplify `collectAgentResults` → `collectAgentOutput` (remove file extraction). Add `pushFromSandbox` + `fixAndRetryPush` | -| `src/workflows/implementation.ts` | Replace `pushChanges(files)` with `pushFromSandbox` + `fixAndRetryPush` | -| `src/workflows/review-fix.ts` | Same as implementation.ts | -| `src/adapters/vcs/github.ts` | `push()` method no longer called for agent work (keep for other uses or remove) | +| `src/sandbox/manager.ts` | Remove `depth: 1`. Replace bare-repo setup with `git remote set-url` to strip auth. Remove `--unshallow` from merge fetch. | +| `src/lib/prompts.ts` | Remove push instruction from Quality Gate in both prompts. | +| `src/sandbox/poll-agent.ts` | Remove unshallow/shallow-check/chain-verify logic from `pushFromSandbox`. | +| `src/sandbox/poll-agent.test.ts` | Remove shallow/unshallow test cases. Simplify push tests. | +| `e2e/tier2/shallow-push.test.ts` | Delete — no longer relevant (no shallow clones). | +| `src/adapters/vcs/github.ts` | `push()` method no longer called for agent work (keep for other uses or remove). | ## What's Preserved - All agent commits with their original messages - Full commit history on the PR (not squashed) -- Pre-push hooks fire naturally during `git push` -- Agent can fix hook failures before exiting -- Merge commits in review-fix flow (natural git merge, not API-fabricated) +- Token security — agent never has push access +- Merge commits in review-fix flow (natural git merge) +- Fix agent for push failures -## What's Removed +## What's Removed (vs. current code) -- File extraction in `collectAgentResults` (`git diff` + `readFileToBuffer` loop) -- Hardcoded `"feat: agent implementation"` commit message -- GitHub API push flow (blob → tree → commit → updateRef) for agent work +- `depth: 1` shallow cloning +- Local bare repo (`/tmp/push-target.git`) setup +- Unshallow step (`git fetch --unshallow origin`) +- Shallow repository detection +- Commit chain verification (`git rev-list --count HEAD`) +- Push instruction in agent prompts (agent commits only) +- E2E shallow push test ## Edge Cases | Case | Handling | |------|----------| -| No pre-push hooks in target repo | Push to local bare succeeds, real push succeeds. No change. | -| Agent doesn't push (forgets/skips) | Commits are still in sandbox. `pushFromSandbox` pushes them. | -| Pre-push hook fails during real push | `fixAndRetryPush` spawns fix agent, retries once. | +| Agent doesn't commit | `pushFromSandbox` detects baseSha == HEAD, returns error. Workflow moves ticket to backlog. | +| Push fails (pre-push hook on GitHub, network) | `fixAndRetryPush` spawns fix agent, retries once. | | Fix agent also fails | Move ticket to backlog with error details. | -| Sandbox dies between agent exit and push | Existing `"stopped"` detection catches this — ticket moves to backlog. | -| Agent makes zero commits | `pushFromSandbox` detects baseSha == HEAD, returns `{ pushed: false }`. Workflow handles as no-op or failure based on agent output. | -| Shallow clone push to GitHub | Works — GitHub has the parent commit (branch was created from base). Git sends the delta. | -| Token in .git/config from clone | Removed in step 2 (`git remote remove origin`). | +| Sandbox dies between agent exit and push | Existing `"stopped"` detection catches this. | +| Large repository (slow full clone) | Accepted trade-off. Clone overhead is negligible vs. 5-35 min agent runtime. | +| Token in .git/config from clone | Stripped immediately via `git remote set-url` to unauthenticated URL. | ## Security -- **Agent never sees the GitHub token.** Remote is sanitized immediately after clone. +- **Agent never sees the GitHub token.** Origin URL stripped of auth immediately after clone. - **Token injected only after agent process exits.** The sentinel file `/tmp/agent-done` confirms the agent is dead before any token enters the sandbox. -- **Token exists briefly** in the sandbox git config during step 10, then sandbox is torn down. +- **Token exists briefly** in the sandbox git config during push, then sandbox is torn down. - **Fix agent (step 9)** runs with the token in git config, but this is a controlled, - short-lived session with a narrow prompt — not the main agent with full autonomy. + short-lived session with a narrow prompt. diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 9bb5dd6..fc7246e 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -49,7 +49,7 @@ You have access to **superpowers skills** installed globally. Use them — they 9. **Update session memory** — write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). 10. Commit your work with descriptive commit messages that explain the "why", not just the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -11. Run all quality checks and push (see Quality Gate below). +11. Run all quality checks (see Quality Gate below). ## When to Ask for Clarification @@ -114,9 +114,6 @@ Before finishing, you MUST: - Find and run ALL quality checks in the project: tests, linting, type checking, formatting, and any other validation scripts. - Fix all failures and commit your fixes with descriptive messages. -- Push your work to origin (\`git push origin \`). - - If the push fails due to pre-push hooks, fix the issues, commit, and push again. - - If the push succeeds, you are clear to finish. ## Output @@ -166,8 +163,8 @@ You have access to **superpowers skills** installed globally. Use them to improv 6. Self-review your changes. 7. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. 8. **Update session memory** — before returning your result, write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -9. Commit your work with descriptive commit messages that explain the "why", not just the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -10. Run all quality checks and push (see Quality Gate below). +9. Commit your work with descriptive commit messages that explain the "why", not just the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). +10. Run all quality checks (see Quality Gate below). ## Comment Overrides @@ -211,9 +208,6 @@ Before finishing, you MUST: - Find and run ALL quality checks in the project: tests, linting, type checking, formatting, and any other validation scripts. - Fix all failures and commit your fixes with descriptive messages. -- Push your work to origin (\`git push origin \`). - - If the push fails due to pre-push hooks, fix the issues, commit, and push again. - - If the push succeeds, you are clear to finish. ## Output diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index f2582f2..b484a3a 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -49,7 +49,6 @@ export class SandboxManager { username: "x-access-token", password: this.config.githubToken, revision: branch, - depth: 1, }, runtime: "node24", timeout: this.config.jobTimeoutMs, @@ -61,18 +60,17 @@ export class SandboxManager { }, }); - // Sanitize remote — remove origin (may contain token from clone) and - // set up a local bare repo as the push target so the agent never sees - // the GitHub token. Pre-push hooks fire naturally on `git push`. - await sandbox.runCommand("bash", [ - "-c", - [ - "git remote remove origin", - "git init --bare /tmp/push-target.git", - "git remote add origin /tmp/push-target.git", - ].join(" && "), + // Strip auth from origin — the clone URL contains the token, replace it + // with the unauthenticated URL so the agent never has push access. + await sandbox.runCommand("git", [ + "remote", "set-url", "origin", + `https://github.com/${this.config.owner}/${this.config.repo}.git`, ]); + // The sandbox clones a specific revision, which leaves git in detached HEAD. + // Create a local branch so pushFromSandbox can push without HEAD resolution issues. + await sandbox.runCommand("git", ["checkout", "-B", branch]); + // Configure git identity await sandbox.runCommand("bash", [ "-c", @@ -85,7 +83,7 @@ export class SandboxManager { const repoUrl = `https://x-access-token:${this.config.githubToken}@github.com/${this.config.owner}/${this.config.repo}.git`; const fetchResult = await sandbox.runCommand("bash", [ "-c", - `git fetch --unshallow "${repoUrl}" ${mergeBase} 2>&1`, + `git fetch "${repoUrl}" ${mergeBase} 2>&1`, ]); // Create a named local branch so the agent can reference it (e.g. `git show main:path`) await sandbox.runCommand("bash", [ diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index bb911c9..f8912c8 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -115,7 +115,7 @@ describe("pushFromSandbox", () => { expect(result.error).toContain("no commits"); }); - it("pushes successfully when agent made commits", async () => { + it("pushes successfully", async () => { const callIndex = { value: 0 }; mockRunCommand.mockImplementation((..._args: unknown[]) => { const i = callIndex.value++; @@ -137,8 +137,7 @@ describe("pushFromSandbox", () => { const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); expect(result.pushed).toBe(true); - // Verify git push was called with args array (no shell injection) - expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "origin", "HEAD:refs/heads/blazebot/task-1"]); + expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "--force", "origin", "HEAD:refs/heads/blazebot/task-1"]); }); it("returns error when push fails", async () => { @@ -150,6 +149,7 @@ describe("pushFromSandbox", () => { } else if (i === 1) { return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; } else if (i === 2) { + // git remote set-url return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; } else { // git push fails @@ -177,6 +177,7 @@ describe("pushFromSandbox", () => { } else if (i === 1) { return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; } else if (i === 2) { + // git remote set-url return { exitCode: 0, stdout: vi.fn().mockResolvedValue("") }; } else { return { exitCode: 0, stdout: vi.fn().mockResolvedValue(""), stderr: vi.fn().mockResolvedValue("") }; @@ -217,7 +218,7 @@ describe("fixAndRetryPush", () => { expect.objectContaining({ path: "/tmp/fix-prompt.txt" }), ]); // Verify push uses args array - expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "origin", "HEAD:refs/heads/blazebot/task-1"]); + expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "--force", "origin", "HEAD:refs/heads/blazebot/task-1"]); }); it("returns error when retry push also fails", async () => { diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 1416e48..4081410 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -93,14 +93,11 @@ export async function pushFromSandbox( const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); - // Unshallow so git can negotiate objects with GitHub during push. - // The sandbox clones with depth:1 — without full history, push fails with - // "Could not read " when the remote has commits the shallow clone can't traverse. - await sandbox.runCommand("git", ["fetch", "--unshallow", "origin"]); - // Push to GitHub — use HEAD: so it works even if the local branch name - // doesn't match (e.g. shallow clone leaves HEAD detached). - const result = await sandbox.runCommand("git", ["push", "origin", `HEAD:refs/heads/${branch}`]); + // doesn't match. Use --force for retries where the branch already has commits + // from a prior failed run. Safe because these are bot-created branches with + // no concurrent pushers. + const result = await sandbox.runCommand("git", ["push", "--force", "origin", `HEAD:refs/heads/${branch}`]); if (result.exitCode !== 0) { const stdout = (await result.stdout()).trim(); @@ -116,10 +113,8 @@ export async function pushFromSandbox( * spawns a lightweight fix agent in the same sandbox to resolve the issue, * then retries the push once. * - * SECURITY NOTE: The fix agent runs with the GitHub token present in - * .git/config (set by the prior `pushFromSandbox` call). This is a deliberate - * trade-off — the agent is short-lived, narrowly prompted, and the sandbox is - * torn down immediately after. + * The fix agent never has push access — the token is stripped before it runs + * and re-injected only after it exits, matching the main agent's security model. */ export async function fixAndRetryPush( sandboxId: string, @@ -131,8 +126,14 @@ export async function fixAndRetryPush( const { env } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + // Strip token from origin before the fix agent runs — agent only commits, never pushes. + await sandbox.runCommand("git", [ + "remote", "set-url", "origin", + `https://github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`, + ]); + // Write prompt to a file to avoid shell injection via pushError content - const fixPrompt = `The git push failed with this error:\n\n${pushError}\n\nFix the issues, commit your fixes, then push to origin.`; + const fixPrompt = `The git push failed with this error:\n\n${pushError}\n\nFix the issues and commit your fixes. Do NOT push.`; await sandbox.writeFiles([ { path: "/tmp/fix-prompt.txt", content: Buffer.from(fixPrompt) }, ]); @@ -149,8 +150,11 @@ export async function fixAndRetryPush( console.log(`[fixAndRetryPush] fix agent output: ${fixLog.slice(0, 500)}`); } - // Retry push — use HEAD: to handle detached HEAD from shallow clone - const result = await sandbox.runCommand("git", ["push", "origin", `HEAD:refs/heads/${branch}`]); + // Re-inject token and push — server pushes, not the agent. + const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; + await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + + const result = await sandbox.runCommand("git", ["push", "--force", "origin", `HEAD:refs/heads/${branch}`]); if (result.exitCode !== 0) { const stdout = (await result.stdout()).trim(); From e9578e88733f05b8551d7fc0e50a27db062a81fa Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 3 Apr 2026 11:45:22 +0200 Subject: [PATCH 06/71] fix: branch creation --- .claude/learnings.md | 4 ++++ src/adapters/vcs/github.test.ts | 22 ++++++++++++++++++++++ src/adapters/vcs/github.ts | 13 ++++++++++++- src/sandbox/manager.ts | 2 +- src/sandbox/poll-agent.ts | 7 +++++++ src/sandbox/wrapper-script.ts | 7 +++---- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.claude/learnings.md b/.claude/learnings.md index 805fa6d..5d3ba72 100644 --- a/.claude/learnings.md +++ b/.claude/learnings.md @@ -3,3 +3,7 @@ - `ChatConfig` requires both `state: StateAdapter` and `userName: string` as mandatory fields. There is no built-in no-op state adapter exported; you must implement the `StateAdapter` interface yourself for outbound-only use cases. - `createSlackAdapter` accepts `SlackAdapterConfig` — the bot token field is `botToken`, not `token`. - `StartOptions` in `workflow/api` has no `id` property; valid options are `deploymentId`, `world`, and `specVersion`. + +## Sandbox push & branch creation +- `@vercel/sandbox` git clones can be shallow by default, causing "no history in common with main" on PR creation when force-pushing from the sandbox. Always unshallow before pushing (`git fetch --unshallow origin`). +- `GitHubAdapter.createBranch` must force-reset existing branches to the base SHA on 422, not silently return. Stale branches from previous failed runs can retain orphan history. diff --git a/src/adapters/vcs/github.test.ts b/src/adapters/vcs/github.test.ts index dbe24ec..dc922ae 100644 --- a/src/adapters/vcs/github.test.ts +++ b/src/adapters/vcs/github.test.ts @@ -5,6 +5,7 @@ const mockOctokit = { git: { getRef: vi.fn(), createRef: vi.fn(), + updateRef: vi.fn(), }, repos: { createOrUpdateFileContents: vi.fn(), @@ -72,6 +73,27 @@ describe("GitHubAdapter", () => { expect.objectContaining({ sha: "seed123" }), ); }); + + it("force-resets existing branch to base SHA on 422", async () => { + mockOctokit.git.getRef.mockResolvedValueOnce({ + data: { object: { sha: "base-sha" } }, + }); + const error = new Error("Reference already exists") as any; + error.status = 422; + mockOctokit.git.createRef.mockRejectedValueOnce(error); + mockOctokit.git.updateRef.mockResolvedValueOnce({ data: {} }); + + const adapter = ghAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockOctokit.git.updateRef).toHaveBeenCalledWith({ + owner: "test-org", + repo: "test-repo", + ref: "heads/feat/test", + sha: "base-sha", + force: true, + }); + }); }); describe("createPR", () => { diff --git a/src/adapters/vcs/github.ts b/src/adapters/vcs/github.ts index 9221caf..971ff5d 100644 --- a/src/adapters/vcs/github.ts +++ b/src/adapters/vcs/github.ts @@ -43,7 +43,17 @@ export class GitHubAdapter implements VCSAdapter { }); } catch (err: any) { if (err.status === 422) { - // Branch already exists — idempotent, nothing to do + // Branch already exists — force-reset it to the base SHA so the next + // sandbox run starts with history rooted in the base branch. Without + // this, a stale branch from a previous failed run (e.g. one pushed from + // a shallow clone) would retain orphan history, causing "no history in + // common with main" errors on PR creation. + await this.octokit.git.updateRef({ + ...this.ownerRepo, + ref: `heads/${name}`, + sha: baseSha, + force: true, + }); return; } throw err; @@ -169,6 +179,7 @@ export class GitHubAdapter implements VCSAdapter { ...this.ownerRepo, issue_number: prId, }); + const comments: PRComment[] = [ ...reviewComments.map((c) => ({ author: c.user?.login ?? "unknown", diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index b484a3a..e2dec32 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -147,7 +147,7 @@ export class SandboxManager { // Write requirements.md and wrapper script for detached execution const wrapperScript = buildWrapperScript({ model: this.config.claudeModel }); await sandbox.writeFiles([ - { path: "requirements.md", content: Buffer.from(requirementsMd) }, + { path: "/tmp/requirements.md", content: Buffer.from(requirementsMd) }, { path: "/tmp/agent-wrapper.sh", content: Buffer.from(wrapperScript) }, ]); await sandbox.runCommand("chmod", ["+x", "/tmp/agent-wrapper.sh"]); diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 4081410..7a796a9 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -93,6 +93,13 @@ export async function pushFromSandbox( const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + // Unshallow if needed — shallow clones cause "no history in common with main" + // errors on PR creation because the pushed commits lack shared ancestry. + await sandbox.runCommand("bash", [ + "-c", + 'if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then git fetch --unshallow origin; fi', + ]); + // Push to GitHub — use HEAD: so it works even if the local branch name // doesn't match. Use --force for retries where the branch already has commits // from a prior failed run. Safe because these are bot-created branches with diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts index 6e424da..4dbce2a 100644 --- a/src/sandbox/wrapper-script.ts +++ b/src/sandbox/wrapper-script.ts @@ -7,7 +7,7 @@ interface WrapperScriptOptions { /** * Generates a bash wrapper script that: * 1. Runs claude --print with the given model (agent commits via stop hook) - * 2. Does cleanup (removes .claude/, requirements.md artifacts) + * 2. Does cleanup (removes .claude/ artifacts) * 3. Writes stdout/stderr to /tmp/ files * 4. Touches /tmp/agent-done as sentinel * @@ -23,7 +23,7 @@ export function buildWrapperScript(opts: WrapperScriptOptions): string { return `#!/bin/bash # --- Phase 1: Run Claude Code agent --- -cat /vercel/sandbox/requirements.md | claude \\ +cat /tmp/requirements.md | claude \\ --print \\ --model '${model}' \\ --dangerously-skip-permissions \\ @@ -36,9 +36,8 @@ cd /vercel/sandbox # Remove repo-level .claude/ artifacts that Claude Code auto-creates. # git checkout restores any that were already committed. -rm -rf .claude/ requirements.md +rm -rf .claude/ git checkout -- .claude/ 2>/dev/null || true -git checkout -- requirements.md 2>/dev/null || true # --- Phase 3: Signal completion --- touch /tmp/agent-done From 13df78b930f0f66f2913e9392002bce8fa812d04 Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:02:35 +0200 Subject: [PATCH 07/71] feat: implement new agent flow (#43) --- .../2026-04-06-three-phase-agent-workflow.md | 1942 +++++++++++++++++ .../2026-04-06-three-phase-workflow-design.md | 504 +++++ src/lib/dispatch.test.ts | 36 +- src/lib/dispatch.ts | 32 +- src/lib/prompts.ts | 263 +-- src/sandbox/agent-runner.test.ts | 97 + src/sandbox/agent-runner.ts | 108 + src/sandbox/context.test.ts | 165 +- src/sandbox/context.ts | 142 +- src/sandbox/manager.test.ts | 80 +- src/sandbox/manager.ts | 77 +- src/sandbox/poll-agent.test.ts | 147 +- src/sandbox/poll-agent.ts | 109 +- src/sandbox/run-agent.ts | 19 - src/sandbox/wrapper-script.test.ts | 103 +- src/sandbox/wrapper-script.ts | 51 +- src/workflows/agent.ts | 465 ++++ src/workflows/implementation.ts | 233 -- src/workflows/review-fix.ts | 235 -- 19 files changed, 3823 insertions(+), 985 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-06-three-phase-agent-workflow.md create mode 100644 docs/superpowers/specs/2026-04-06-three-phase-workflow-design.md delete mode 100644 src/sandbox/run-agent.ts create mode 100644 src/workflows/agent.ts delete mode 100644 src/workflows/implementation.ts delete mode 100644 src/workflows/review-fix.ts diff --git a/docs/superpowers/plans/2026-04-06-three-phase-agent-workflow.md b/docs/superpowers/plans/2026-04-06-three-phase-agent-workflow.md new file mode 100644 index 0000000..a0c10c2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-three-phase-agent-workflow.md @@ -0,0 +1,1942 @@ +# Three-Phase Agent Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace both `implementationWorkflow` and `reviewFixWorkflow` with a single unified `agentWorkflow` that splits work into three phases (research & plan → implementation → review) within one sandbox. + +**Architecture:** The workflow provisions a single Vercel Sandbox and runs three sequential `claude --print` invocations. Between each phase, the workflow checks the result and decides whether to proceed, retry, or fail fast. Phase 1 outputs free-form markdown; Phases 2 and 3 use structured JSON schemas. The review phase can loop back to implementation up to 2 times. + +**Tech Stack:** TypeScript, Nitro, Vercel Workflow SDK (`"use workflow"` / `"use step"`), Vercel Sandbox, Zod, Vitest + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/sandbox/agent-runner.ts` | Modify | Add `REVIEW_SCHEMA`, `ReviewOutput`, `parseReviewOutput()`, `parseResearchStatus()` | +| `src/sandbox/agent-runner.test.ts` | Modify | Tests for new parsers | +| `src/sandbox/wrapper-script.ts` | Rewrite | `buildPhaseScript(opts)` replacing `buildWrapperScript(opts)` | +| `src/sandbox/wrapper-script.test.ts` | Rewrite | Tests for parameterized script builder | +| `src/sandbox/context.ts` | Rewrite | New assembly functions for all three phases | +| `src/sandbox/context.test.ts` | Rewrite | Tests for all new context assemblers | +| `src/sandbox/run-agent.ts` | Modify | Generalize to accept phase script path | +| `src/sandbox/poll-agent.ts` | Modify | Generalize sentinel/output file paths | +| `src/sandbox/poll-agent.test.ts` | Modify | Update tests for generalized functions | +| `src/sandbox/manager.ts` | Modify | Extract stop-hook toggling, remove wrapper script writing from provision | +| `src/sandbox/manager.test.ts` | Modify | Update tests | +| `src/lib/prompts.ts` | Rewrite | Three new prompts, remove old two | +| `src/workflows/agent.ts` | Create | Unified three-phase workflow | +| `src/workflows/implementation.ts` | Delete | Replaced by agent.ts | +| `src/workflows/review-fix.ts` | Delete | Absorbed into agent.ts | +| `src/lib/dispatch.ts` | Modify | Always start `agentWorkflow`, remove branching | +| `src/lib/dispatch.test.ts` | Modify | Update tests for unified workflow | + +--- + +### Task 1: Add review output schema and research status parser to agent-runner + +**Files:** +- Modify: `src/sandbox/agent-runner.ts` +- Modify: `src/sandbox/agent-runner.test.ts` + +- [ ] **Step 1: Write failing tests for `parseResearchStatus`** + +Add to `src/sandbox/agent-runner.test.ts`: + +```typescript +describe("parseResearchStatus", () => { + it("extracts completed status", () => { + const raw = "STATUS: completed\n\n# Implementation Plan\n1. Create foo.ts"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("completed"); + expect(body).toContain("# Implementation Plan"); + }); + + it("extracts clarification_needed status", () => { + const raw = "STATUS: clarification_needed\n\n1. What database?\n2. Which auth?"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("clarification_needed"); + expect(body).toContain("What database?"); + }); + + it("extracts failed status", () => { + const raw = "STATUS: failed\n\nCould not access repository"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("failed"); + }); + + it("defaults to failed when no STATUS line", () => { + const raw = "Here is my analysis of the codebase..."; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("failed"); + expect(body).toContain("analysis"); + }); + + it("handles STATUS line with extra whitespace", () => { + const raw = " STATUS: completed \n\nPlan here"; + const { status } = parseResearchStatus(raw); + expect(status).toBe("completed"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/sandbox/agent-runner.test.ts` +Expected: FAIL — `parseResearchStatus` is not exported + +- [ ] **Step 3: Implement `parseResearchStatus`** + +Add to `src/sandbox/agent-runner.ts`: + +```typescript +export type ResearchStatus = "completed" | "clarification_needed" | "failed"; + +export interface ResearchResult { + status: ResearchStatus; + body: string; +} + +const VALID_RESEARCH_STATUSES: ResearchStatus[] = ["completed", "clarification_needed", "failed"]; + +export function parseResearchStatus(raw: string): ResearchResult { + const lines = raw.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + const match = firstLine.match(/^STATUS:\s*(\S+)/i); + + if (match && VALID_RESEARCH_STATUSES.includes(match[1] as ResearchStatus)) { + const body = lines.slice(1).join("\n").trim(); + return { status: match[1] as ResearchStatus, body }; + } + + return { status: "failed", body: raw }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/sandbox/agent-runner.test.ts` +Expected: All `parseResearchStatus` tests PASS + +- [ ] **Step 5: Write failing tests for `parseReviewOutput` and `REVIEW_SCHEMA`** + +Add to `src/sandbox/agent-runner.test.ts`: + +```typescript +describe("parseReviewOutput", () => { + it("parses approved result", () => { + const raw = JSON.stringify({ + result: "approved", + feedback: "Looks good", + issues: [], + }); + const output = parseReviewOutput(raw); + expect(output.result).toBe("approved"); + expect(output.feedback).toBe("Looks good"); + }); + + it("parses changes_requested result with issues", () => { + const raw = JSON.stringify({ + result: "changes_requested", + feedback: "Several issues found", + issues: [ + { file: "src/foo.ts", description: "Missing null check", severity: "critical" }, + ], + }); + const output = parseReviewOutput(raw); + expect(output.result).toBe("changes_requested"); + expect(output.issues).toHaveLength(1); + expect(output.issues[0].severity).toBe("critical"); + }); + + it("returns failed on unparseable output", () => { + const output = parseReviewOutput("not json"); + expect(output.result).toBe("failed"); + expect(output.error).toBeDefined(); + }); + + it("returns failed on empty output", () => { + const output = parseReviewOutput(""); + expect(output.result).toBe("failed"); + }); + + it("extracts from result envelope", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + structured_output: { + result: "approved", + feedback: "All good", + issues: [], + }, + }); + const output = parseReviewOutput(envelope); + expect(output.result).toBe("approved"); + }); +}); + +describe("REVIEW_SCHEMA", () => { + it("is valid JSON", () => { + expect(() => JSON.parse(REVIEW_SCHEMA)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `npx vitest run src/sandbox/agent-runner.test.ts` +Expected: FAIL — `parseReviewOutput` and `REVIEW_SCHEMA` not exported + +- [ ] **Step 7: Implement `ReviewOutput`, `REVIEW_SCHEMA`, and `parseReviewOutput`** + +Add to `src/sandbox/agent-runner.ts`: + +```typescript +const reviewOutputSchema = z.object({ + result: z.enum(["approved", "changes_requested", "failed"]), + feedback: z.string().optional(), + issues: z.array(z.object({ + file: z.string(), + description: z.string(), + severity: z.enum(["critical", "suggestion"]), + })).optional(), + error: z.string().optional(), +}); + +export type ReviewOutput = z.infer; + +export const REVIEW_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { + type: "string", + enum: ["approved", "changes_requested", "failed"], + }, + feedback: { type: "string" }, + issues: { + type: "array", + items: { + type: "object", + properties: { + file: { type: "string" }, + description: { type: "string" }, + severity: { type: "string", enum: ["critical", "suggestion"] }, + }, + required: ["file", "description", "severity"], + }, + }, + error: { type: "string" }, + }, + required: ["result"], +}); + +export function parseReviewOutput(raw: string): ReviewOutput { + if (!raw.trim()) { + return { result: "failed", error: "Review agent produced no output" }; + } + + // Direct parse + try { + const direct = reviewOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch {} + + // Stream-json / result-envelope format + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + + if (event.type === "result" && event.structured_output != null) { + const parsed = reviewOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + + const direct = reviewOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch {} + } + + // Fallback: extract JSON objects + const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); + for (const [candidate] of objects) { + try { + const result = reviewOutputSchema.safeParse(JSON.parse(candidate)); + if (result.success) return result.data; + } catch {} + } + + return { + result: "failed", + error: `Review output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, + }; +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx vitest run src/sandbox/agent-runner.test.ts` +Expected: All tests PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/sandbox/agent-runner.ts src/sandbox/agent-runner.test.ts +git commit -m "feat: add review output schema and research status parser" +``` + +--- + +### Task 2: Parameterize wrapper script builder + +**Files:** +- Rewrite: `src/sandbox/wrapper-script.ts` +- Rewrite: `src/sandbox/wrapper-script.test.ts` + +- [ ] **Step 1: Write failing tests for `buildPhaseScript`** + +Replace `src/sandbox/wrapper-script.test.ts` with: + +```typescript +import { describe, it, expect } from "vitest"; +import { buildPhaseScript } from "./wrapper-script.js"; + +describe("buildPhaseScript", () => { + it("generates research phase script without json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); + + expect(script).toContain("#!/bin/bash"); + expect(script).toContain("claude"); + expect(script).toContain("claude-opus-4-6"); + expect(script).toContain("/tmp/research-requirements.md"); + expect(script).toContain("/tmp/research-stdout.txt"); + expect(script).toContain("/tmp/research-stderr.txt"); + expect(script).toContain("/tmp/research-done"); + expect(script).not.toContain("--json-schema"); + expect(script).not.toContain("--output-format"); + }); + + it("generates impl phase script with json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: '{"type":"object"}', + }); + + expect(script).toContain("--json-schema"); + expect(script).toContain("--output-format json"); + expect(script).toContain("/tmp/impl-requirements.md"); + expect(script).toContain("/tmp/impl-done"); + }); + + it("generates review phase script with json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "review", + inputFile: "/tmp/review-requirements.md", + outputFile: "/tmp/review-stdout.txt", + stderrFile: "/tmp/review-stderr.txt", + sentinelFile: "/tmp/review-done", + jsonSchema: '{"type":"object"}', + }); + + expect(script).toContain("--json-schema"); + expect(script).toContain("/tmp/review-requirements.md"); + expect(script).toContain("/tmp/review-done"); + }); + + it("includes cleanup and sentinel touch", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); + + expect(script).toContain("rm -rf .claude/"); + expect(script).toContain("touch /tmp/research-done"); + }); + + it("escapes single quotes in json schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: `{"type":"object","desc":"it's"}`, + }); + + expect(script).not.toContain("it's"); + expect(script).toContain("it'\\''s"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/sandbox/wrapper-script.test.ts` +Expected: FAIL — `buildPhaseScript` not exported + +- [ ] **Step 3: Implement `buildPhaseScript`** + +Replace `src/sandbox/wrapper-script.ts` with: + +```typescript +export interface PhaseScriptOptions { + model: string; + phase: "research" | "impl" | "review"; + inputFile: string; + outputFile: string; + stderrFile: string; + sentinelFile: string; + jsonSchema?: string; +} + +/** + * Generates a bash script for a single agent phase. + * Designed to run detached inside a Vercel Sandbox. + */ +export function buildPhaseScript(opts: PhaseScriptOptions): string { + const { model, inputFile, outputFile, stderrFile, sentinelFile, jsonSchema } = opts; + + let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions`; + + if (jsonSchema) { + const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); + claudeFlags += ` --output-format json --json-schema '${escapedSchema}'`; + } + + return `#!/bin/bash + +# --- Phase: ${opts.phase} --- +cat ${inputFile} | claude \\ + ${claudeFlags} \\ + > ${outputFile} 2>${stderrFile}; echo $? > /tmp/${opts.phase}-exit-code || true + +# --- Cleanup --- +cd /vercel/sandbox + +# Remove repo-level .claude/ artifacts that Claude Code auto-creates. +# git checkout restores any that were already committed. +rm -rf .claude/ +git checkout -- .claude/ 2>/dev/null || true + +# --- Signal completion --- +touch ${sentinelFile} +`; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/sandbox/wrapper-script.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/sandbox/wrapper-script.ts src/sandbox/wrapper-script.test.ts +git commit -m "feat: parameterize wrapper script for multi-phase execution" +``` + +--- + +### Task 3: Rewrite context assembly functions + +**Files:** +- Rewrite: `src/sandbox/context.ts` +- Rewrite: `src/sandbox/context.test.ts` + +- [ ] **Step 1: Write failing tests for `assembleResearchPlanContext`** + +Add to `src/sandbox/context.test.ts` (keep existing `formatCheckResults` tests, replace `assembleImplementationContext` and `assembleFixingFeedbackContext` tests): + +```typescript +describe("assembleResearchPlanContext", () => { + it("assembles context for new ticket (no PR feedback)", () => { + const result = assembleResearchPlanContext({ + ticket: { + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], + }, + prompt: "You are a research agent...", + branchName: "blazebot/test-1", + }); + + expect(result).toContain("## Ticket ID"); + expect(result).toContain("TEST-1"); + expect(result).toContain("## Branch"); + expect(result).toContain("blazebot/test-1"); + expect(result).toContain("You are a research agent..."); + expect(result).not.toContain("## PR Review Feedback"); + }); + + it("assembles context with PR feedback for review-fix scenario", () => { + const result = assembleResearchPlanContext({ + ticket: { + identifier: "TEST-2", + title: "Fix auth", + description: "Fix auth module", + acceptanceCriteria: "", + comments: [], + }, + prompt: "prompt", + branchName: "blazebot/test-2", + prComments: [ + { author: "Bob", body: "Fix the null check", liked: false }, + ], + checkResults: [ + { name: "test", status: "completed", conclusion: "failure", logs: "FAIL" }, + ], + hasConflicts: true, + }); + + expect(result).toContain("## PR Review Feedback"); + expect(result).toContain("Fix the null check"); + expect(result).toContain("## CI/CD Check Results"); + expect(result).toContain("### Failed: test"); + expect(result).toContain("## Merge Conflicts"); + }); +}); +``` + +- [ ] **Step 2: Write failing tests for `assembleImplementationContext` (new signature)** + +```typescript +describe("assembleImplementationContext (new)", () => { + it("assembles context with research plan markdown", () => { + const result = assembleImplementationContext({ + ticket: { + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], + }, + prompt: "You are an implementation agent...", + researchPlanMarkdown: "# Plan\n1. Create LoginForm component\n2. Add route handler", + }); + + expect(result).toContain("## Ticket ID"); + expect(result).toContain("TEST-1"); + expect(result).toContain("## Research & Plan"); + expect(result).toContain("# Plan"); + expect(result).toContain("Create LoginForm component"); + expect(result).toContain("You are an implementation agent..."); + }); +}); +``` + +- [ ] **Step 3: Write failing tests for `assembleImplementationRetryContext`** + +```typescript +describe("assembleImplementationRetryContext", () => { + it("includes plan and review feedback", () => { + const result = assembleImplementationRetryContext({ + ticket: { + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], + }, + prompt: "prompt", + researchPlanMarkdown: "# Plan\n1. Create LoginForm", + reviewFeedback: { + result: "changes_requested", + feedback: "Missing error handling", + issues: [ + { file: "src/LoginForm.tsx", description: "No null check", severity: "critical" }, + ], + }, + }); + + expect(result).toContain("## Research & Plan"); + expect(result).toContain("Create LoginForm"); + expect(result).toContain("## Review Feedback"); + expect(result).toContain("Missing error handling"); + expect(result).toContain("src/LoginForm.tsx"); + expect(result).toContain("No null check"); + expect(result).toContain("critical"); + }); +}); +``` + +- [ ] **Step 4: Write failing tests for `assembleReviewContext`** + +```typescript +describe("assembleReviewContext", () => { + it("includes plan and git diff", () => { + const result = assembleReviewContext({ + ticket: { + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], + }, + prompt: "You are a review agent...", + researchPlanMarkdown: "# Plan\n1. Create LoginForm", + gitDiff: "diff --git a/src/LoginForm.tsx b/src/LoginForm.tsx\n+export function LoginForm() {}", + }); + + expect(result).toContain("## Research & Plan"); + expect(result).toContain("## Git Diff"); + expect(result).toContain("+export function LoginForm()"); + expect(result).toContain("You are a review agent..."); + }); +}); +``` + +- [ ] **Step 5: Run all new tests to verify they fail** + +Run: `npx vitest run src/sandbox/context.test.ts` +Expected: FAIL — new functions not exported + +- [ ] **Step 6: Implement all new context assembly functions** + +Rewrite `src/sandbox/context.ts` — keep `formatCheckResults` and the helper functions (`formatComments`, `formatPRComments`), replace the main assembly functions: + +```typescript +import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; +import type { ReviewOutput } from "./agent-runner.js"; + +interface TicketData { + identifier: string; + title: string; + description: string; + acceptanceCriteria: string; + comments: Array<{ author: string; body: string; createdAt: string }>; +} + +export interface ResearchPlanContextInput { + ticket: TicketData; + prompt: string; + branchName: string; + prComments?: PRComment[]; + checkResults?: CheckRunResult[]; + hasConflicts?: boolean; +} + +export interface ImplementationContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; +} + +export interface ImplementationRetryContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + reviewFeedback: ReviewOutput; +} + +export interface ReviewContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + gitDiff: string; +} + +export function assembleResearchPlanContext(input: ResearchPlanContextInput): string { + const { ticket, prompt, branchName, prComments, checkResults, hasConflicts } = input; + + let md = `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} + +## Description + +${ticket.description} + +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Comments + +${formatComments(ticket.comments)} + +## Branch + +${branchName} +`; + + if (prComments && prComments.length > 0) { + md += `\n## PR Review Feedback\n\n${formatPRComments(prComments)}\n`; + } + + if (checkResults && checkResults.length > 0) { + md += `\n## CI/CD Check Results\n\n${formatCheckResults(checkResults)}\n`; + } + + if (hasConflicts) { + md += `\n## Merge Conflicts\n\nThis PR has merge conflicts. The base branch has already been merged — the repo is in a MERGING state with conflict markers in the affected files. Resolve the markers, \`git add\` the files, and run \`git merge --continue\`.\n`; + } + + md += `\n---\n\n${prompt}\n`; + return md; +} + +export function assembleImplementationContext(input: ImplementationContextInput): string { + const { ticket, prompt, researchPlanMarkdown } = input; + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} + +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +--- + +${prompt} +`; +} + +export function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string { + const { ticket, prompt, researchPlanMarkdown, reviewFeedback } = input; + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} + +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +## Review Feedback + +${reviewFeedback.feedback ?? "No feedback provided."} + +### Issues + +${formatReviewIssues(reviewFeedback.issues ?? [])} + +--- + +${prompt} +`; +} + +export function assembleReviewContext(input: ReviewContextInput): string { + const { ticket, prompt, researchPlanMarkdown, gitDiff } = input; + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} + +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +## Git Diff + +\`\`\`diff +${gitDiff} +\`\`\` + +--- + +${prompt} +`; +} + +function formatReviewIssues(issues: Array<{ file: string; description: string; severity: string }>): string { + if (issues.length === 0) return "No specific issues listed."; + return issues + .map((i) => `- **[${i.severity}]** ${i.file}: ${i.description}`) + .join("\n"); +} + +// Keep existing helpers below unchanged +``` + +Note: Keep the existing `formatComments`, `formatPRComments`, and `formatCheckResults` functions exactly as they are. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npx vitest run src/sandbox/context.test.ts` +Expected: All tests PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/sandbox/context.ts src/sandbox/context.test.ts +git commit -m "feat: rewrite context assembly for three-phase workflow" +``` + +--- + +### Task 4: Write three new prompts + +**Files:** +- Rewrite: `src/lib/prompts.ts` + +- [ ] **Step 1: Replace prompts.ts with three new prompts** + +Rewrite `src/lib/prompts.ts`. Remove `implement.md` (old) and `review-fix.md`. Add `research-plan.md`, `implement.md` (new), and `review.md`: + +```typescript +const researchPlanPrompt = `# Instructions + +You are an AI research agent. Your job is to explore the repository, understand the ticket, and produce a precise implementation plan. + +## Output Format + +Your output MUST start with a STATUS line on the very first line: + +\`\`\` +STATUS: completed +\`\`\` + +Valid statuses: \`completed\`, \`clarification_needed\`, \`failed\` + +Everything after the STATUS line is your research findings and plan. This output will be passed as-is to the implementation agent — keep it clean and actionable. + +## Superpowers + +You have access to **superpowers skills** installed globally. Use them. + +- **Always check for applicable skills before starting work.** The \`using-superpowers\` skill is loaded — follow its guidance. +- **Use \`brainstorming\` to think through the approach** — explore alternatives, consider trade-offs, then settle on the best path. + +## Process + +1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. +2. Explore the repository structure. Read \`CLAUDE.md\`, \`AGENTS.md\` if present. +3. Check \`git log\` and \`git diff\` against the base branch to identify what's already been done on this branch. +4. If PR review feedback or CI/CD failures are included above, understand what needs to be fixed. +5. Identify what's already implemented vs. what remains. +6. Analyze relevant files, code patterns, test setup. +7. **Use the \`brainstorming\` skill** to think through the approach. +8. Produce a precise implementation plan for the remaining work. +9. **Write/update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. + +## Plan Output Constraints + +Your plan MUST be: +- **Actionable only** — each step must be directly executable ("Create file X with Y" not "Consider how to...") +- **Minimal** — no preamble, rationale, or context noise that would confuse the implementation agent +- **Concrete** — file paths must be specific ("src/components/Foo.tsx" not "the relevant component") +- **Structured for top-to-bottom execution** — the implementation agent reads and executes sequentially + +## When to Ask for Clarification + +Return \`STATUS: clarification_needed\` if: +- No clear definition of done in the ticket +- Ambiguous scope +- Missing technical context +- Contradictory requirements +- Multiple valid interpretations +- Missing design/UX details for UI work + +When you need clarification, list your questions as numbered lines after the STATUS line. Batch ALL questions — never return with just one. + +## Constraints + +- **NO coding** — do not write implementation code +- **NO commits** — do not create any git commits +- Only analyze and plan + +## Session Memory + +**MANDATORY** — before returning, overwrite \`blazebot/memory/[TASK_ID].md\`: + +\`\`\`markdown +# Session Memory — [TASK_ID] + +## Progress +- What was analyzed and planned this session + +## Decisions Made +- Technical choices and reasoning + +## Blockers +- What is blocking progress (if clarification_needed or failed) +- "None" if completed successfully + +## Files Touched +- "None — research phase only" + +## Prior Sessions +- Brief summary of prior sessions (if memory file existed) +\`\`\``; + +const implementPrompt = `# Instructions + +You are an AI coding agent executing an implementation plan. The plan was created by a research agent and is included above under "Research & Plan". + +## Superpowers + +You have access to **superpowers skills** installed globally. Use them. + +- **Use \`executing-plans\` to systematically work through the plan** — it structures execution correctly. +- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. +- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. + +## Process + +1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists. If it exists, read it. +2. Read the plan from the "Research & Plan" section above. +3. If review feedback is included (retry scenario): focus on fixing the flagged issues. Do not redo work that was approved. +4. Execute each step in the plan, in order. +5. If the repo has tests: run them to ensure nothing is broken. +6. **Update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. +7. Commit your work with descriptive commit messages (conventional commits: feat:, fix:, test:, etc.). +8. Run all quality checks (tests, linting, type checking, formatting). + +## Constraints + +- Follow the plan — do not explore or re-research (already done). +- Do not refactor code outside the scope of the plan. +- Do not install new dependencies unless the plan specifies them. +- Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). +- Do NOT invoke \`requesting-code-review\` — that happens in a separate review phase. + +## When to Ask for Clarification + +Return \`clarification_needed\` only if the plan is genuinely unexecutable. Exhaust code-level investigation first. + +## Session Memory + +**MANDATORY** — before returning, overwrite \`blazebot/memory/[TASK_ID].md\`: + +\`\`\`markdown +# Session Memory — [TASK_ID] + +## Progress +- What was implemented this session + +## Decisions Made +- Technical choices and reasoning + +## Blockers +- What is blocking progress (if clarification_needed or failed) +- "None" if implemented successfully + +## Files Touched +- List of files created or modified + +## Prior Sessions +- Brief summary of prior sessions (if memory file existed) +\`\`\` + +## Output + +Return a JSON object with: +- \`result\`: "implemented" if done, "clarification_needed" if you have questions, "failed" if stuck. +- \`summary\`: Description of work done (when implemented). +- \`questions\`: List of questions (when clarification_needed). +- \`error\`: Failure details (when failed).`; + +const reviewPrompt = `# Instructions + +You are an AI code review agent. Your job is to review the implementation diff against the plan and acceptance criteria. + +## Superpowers + +You have access to **superpowers skills** installed globally. Use them. + +- **Use \`requesting-code-review\` to dispatch a code-reviewer subagent** — this is your primary tool. + +## Process + +1. Read the plan from the "Research & Plan" section above. +2. Read the acceptance criteria. +3. Review the git diff against the plan — did the implementation agent follow it? +4. Check code quality, test coverage, edge cases. +5. Invoke \`requesting-code-review\` skill to dispatch a code-reviewer subagent. +6. Combine your findings with the subagent's findings. +7. Output your verdict. + +## Review Criteria + +- Does the implementation match the plan? +- Does it satisfy the acceptance criteria? +- Are there test gaps? +- Are there obvious bugs or edge cases? +- Does the code follow existing conventions? + +## Constraints + +- **NO coding** — do not write or modify any code +- **NO commits** — do not create any git commits +- Only review and report + +## Output + +Return a JSON object with: +- \`result\`: "approved" if the implementation is ready, "changes_requested" if issues need fixing, "failed" if review itself failed. +- \`feedback\`: Detailed review notes. +- \`issues\`: Array of specific issues — each with \`file\`, \`description\`, \`severity\` ("critical" or "suggestion"). Only include issues that MUST be fixed for \`changes_requested\`. +- \`error\`: Failure details (when failed).`; + +const prompts: Record = { + "research-plan.md": researchPlanPrompt, + "implement.md": implementPrompt, + "review.md": reviewPrompt, +}; + +export function getPrompt(name: string): string { + const content = prompts[name]; + if (!content) throw new Error(`Unknown prompt: ${name}`); + return content; +} +``` + +- [ ] **Step 2: Run existing tests to check for breakage** + +Run: `npx vitest run` +Expected: Some tests may fail if they reference old prompt names. Note failures for next step. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/prompts.ts +git commit -m "feat: replace monolithic prompts with three phase-specific prompts" +``` + +--- + +### Task 5: Generalize poll-agent and run-agent for multi-phase + +**Files:** +- Modify: `src/sandbox/poll-agent.ts` +- Modify: `src/sandbox/poll-agent.test.ts` +- Modify: `src/sandbox/run-agent.ts` + +- [ ] **Step 1: Write failing tests for generalized `checkPhaseDone` and `collectPhaseOutput`** + +Update `src/sandbox/poll-agent.test.ts` — add tests for the new generalized versions alongside existing tests: + +```typescript +describe("checkPhaseDone", () => { + beforeEach(() => vi.clearAllMocks()); + + it("checks a custom sentinel file", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const { checkPhaseDone } = await import("./poll-agent.js"); + const result = await checkPhaseDone("sbx-test-123", "/tmp/research-done"); + expect(result).toBe(true); + expect(mockRunCommand).toHaveBeenCalledWith("test", ["-f", "/tmp/research-done"]); + }); +}); + +describe("collectPhaseOutput", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reads from a custom output file", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation((...args: any[]) => ({ + exitCode: 0, + stdout: mockStdout, + })); + + mockStdout + .mockResolvedValueOnce(JSON.stringify({ result: "implemented", summary: "Done" })) + .mockResolvedValueOnce(""); + + const { collectPhaseOutput } = await import("./poll-agent.js"); + const result = await collectPhaseOutput("sbx-test-123", "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); + expect(result).toBe(JSON.stringify({ result: "implemented", summary: "Done" })); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/sandbox/poll-agent.test.ts` +Expected: FAIL — `checkPhaseDone` and `collectPhaseOutput` not exported + +- [ ] **Step 3: Add `checkPhaseDone` and `collectPhaseOutput` to poll-agent.ts** + +Add to `src/sandbox/poll-agent.ts` (keep existing functions as-is for backward compat during transition): + +```typescript +/** + * Generalized sentinel check — works with any sentinel file path. + */ +export async function checkPhaseDone( + sandboxId: string, + sentinelFile: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + try { + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + if (sandbox.status !== "running") { + return "stopped"; + } + + const result = await sandbox.runCommand("test", ["-f", sentinelFile]); + return result.exitCode === 0; + } catch { + return "stopped"; + } +} + +/** + * Generalized output collector — reads from any stdout/stderr file paths. + * Returns raw string. Caller is responsible for parsing. + */ +export async function collectPhaseOutput( + sandboxId: string, + outputFile: string, + stderrFile: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + const stdoutResult = await sandbox.runCommand("cat", [outputFile]); + const stdout = (await stdoutResult.stdout()).trim(); + + const stderrResult = await sandbox.runCommand("cat", [stderrFile]); + const stderr = (await stderrResult.stdout()).trim(); + + return stdout || stderr; +} +``` + +- [ ] **Step 4: Generalize `startAgentDetached` in run-agent.ts** + +Update `src/sandbox/run-agent.ts`: + +```typescript +import type { Sandbox as SandboxType } from "@vercel/sandbox"; + +type SandboxInstance = Awaited>; + +/** + * Starts a phase script in detached mode. + * Returns immediately — the agent runs in the background. + */ +export async function startPhaseDetached( + sandbox: SandboxInstance, + scriptPath: string, +): Promise { + await sandbox.runCommand({ + cmd: "bash", + args: [scriptPath], + cwd: "/vercel/sandbox", + detached: true, + }); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/sandbox/poll-agent.test.ts` +Expected: All tests PASS (both old and new) + +- [ ] **Step 6: Commit** + +```bash +git add src/sandbox/poll-agent.ts src/sandbox/poll-agent.test.ts src/sandbox/run-agent.ts +git commit -m "feat: generalize poll-agent and run-agent for multi-phase execution" +``` + +--- + +### Task 6: Refactor sandbox manager for phase-based execution + +**Files:** +- Modify: `src/sandbox/manager.ts` +- Modify: `src/sandbox/manager.test.ts` + +- [ ] **Step 1: Refactor `provision` — remove wrapper script writing, add stop-hook toggle** + +The manager should provision the sandbox (clone, git config, install claude, install skills) but NOT write the wrapper script or requirements. Those are now per-phase and handled by the workflow. + +Update `src/sandbox/manager.ts`: + +1. Remove `import { buildWrapperScript }` and the wrapper script writing from `provision()` +2. Remove `requirementsMd` parameter from `provision()` — it now only takes `branch` and optional `mergeBase` +3. Add a new method `configureStopHook(sandbox, enabled)` that writes or clears `~/.claude/settings.json` +4. Add a new method `writePhaseFiles(sandbox, inputFile, inputContent, scriptPath, scriptContent)` for per-phase file writing + +```typescript +async provision( + branch: string, + mergeBase?: string, +): Promise { + // ... same as before up through installGlobalSkills ... + // REMOVE the wrapper script and requirements.md writing + return sandbox; +} + +async configureStopHook(sandbox: SandboxInstance, enabled: boolean): Promise { + if (enabled) { + await sandbox.runCommand("bash", [ + "-c", + [ + `mkdir -p ~/.claude`, + `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, + `#!/bin/bash`, + `input=$(cat)`, + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/' | grep -v 'requirements\\.md')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + ` exit 2`, + `fi`, + `SCRIPT`, + `chmod +x ~/.claude/commit-guard.sh`, + `cat > ~/.claude/settings.json << 'JSON'`, + `{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`, + `JSON`, + ].join("\n"), + ]); + } else { + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p ~/.claude && echo '{}' > ~/.claude/settings.json`, + ]); + } +} + +async writePhaseFiles( + sandbox: SandboxInstance, + files: Array<{ path: string; content: string }>, +): Promise { + await sandbox.writeFiles( + files.map((f) => ({ path: f.path, content: Buffer.from(f.content) })), + ); + // Make scripts executable + for (const f of files) { + if (f.path.endsWith(".sh")) { + await sandbox.runCommand("chmod", ["+x", f.path]); + } + } +} +``` + +- [ ] **Step 2: Update manager.test.ts** + +Update the tests to reflect the new `provision()` signature (no `requirementsMd`). Add tests for `configureStopHook` and `writePhaseFiles`. + +- [ ] **Step 3: Run tests** + +Run: `npx vitest run src/sandbox/manager.test.ts` +Expected: All tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/sandbox/manager.ts src/sandbox/manager.test.ts +git commit -m "refactor: extract stop-hook config, remove wrapper script from provision" +``` + +--- + +### Task 7: Create the unified `agentWorkflow` + +**Files:** +- Create: `src/workflows/agent.ts` + +- [ ] **Step 1: Create `src/workflows/agent.ts`** + +This is the core of the change. The workflow orchestrates three phases: + +```typescript +import { sleep } from "workflow"; +import type { AgentOutput } from "../sandbox/agent-runner.js"; +import type { ReviewOutput } from "../sandbox/agent-runner.js"; +import type { TicketContent } from "../adapters/issue-tracker/types.js"; +import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; + +// --- Step Functions --- + +async function fetchAndValidateTicket(ticketId: string, columnAi: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + const ticket = await issueTracker.fetchTicket(ticketId); + if (ticket.trackerStatus.toLowerCase() !== columnAi.toLowerCase()) return null; + return ticket; +} + +async function createFeatureBranch(branchName: string, baseBranch: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + await vcs.createBranch(branchName, baseBranch); +} + +async function fetchPRContext(branchName: string): Promise<{ + prComments: PRComment[]; + checkResults: CheckRunResult[]; + hasConflicts: boolean; +} | null> { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + const pr = await vcs.findPR(branchName); + if (!pr) return null; + + const prComments = await vcs.getPRComments(pr.id); + const hasConflicts = await vcs.getPRConflictStatus(pr.id); + const checkResults = await vcs.getCheckRunResults(pr.id); + return { prComments, hasConflicts, checkResults }; +} + +async function provisionSandbox( + branchName: string, + mergeBase?: string, +): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + + const manager = new SandboxManager({ + githubToken: env.GITHUB_TOKEN, + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + }); + + const sandbox = await manager.provision(branchName, mergeBase); + return sandbox.sandboxId; +} +provisionSandbox.maxRetries = 0; + +async function writeAndStartPhase( + sandboxId: string, + inputFilePath: string, + inputContent: string, + scriptPath: string, + scriptContent: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + await sandbox.writeFiles([ + { path: inputFilePath, content: Buffer.from(inputContent) }, + { path: scriptPath, content: Buffer.from(scriptContent) }, + ]); + await sandbox.runCommand("chmod", ["+x", scriptPath]); + + await sandbox.runCommand({ + cmd: "bash", + args: [scriptPath], + cwd: "/vercel/sandbox", + detached: true, + }); +} +writeAndStartPhase.maxRetries = 0; + +async function configureStopHook(sandboxId: string, enabled: boolean): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + const { env } = await import("../../env.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const manager = new SandboxManager({ + githubToken: env.GITHUB_TOKEN, + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + }); + await manager.configureStopHook(sandbox, enabled); +} + +async function captureGitDiff(sandboxId: string): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const baseShaResult = await sandbox.runCommand("bash", [ + "-c", "cat /tmp/.pre-agent-sha 2>/dev/null || echo ''", + ]); + const baseSha = (await baseShaResult.stdout()).trim(); + + const diffCmd = baseSha + ? `git diff ${baseSha}..HEAD` + : "git diff HEAD"; + const diffResult = await sandbox.runCommand("bash", ["-c", diffCmd]); + return (await diffResult.stdout()).trim(); +} + +// Reuse existing step functions from implementation.ts for: +// createPullRequest, moveTicket, notifySlack, postClarificationAndMoveBack, +// unregisterRun, markTicketFailed +// (Copy them here — they're identical) + +async function createPullRequest(branchName: string, title: string, summary: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + return vcs.createPR(branchName, title, summary); +} + +async function moveTicket(ticketId: string, column: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + await issueTracker.moveTicket(ticketId, column); +} + +async function notifySlack(message: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { messaging } = createStepAdapters(); + await messaging.notify(message); +} + +async function postClarificationAndMoveBack( + ticketId: string, questions: string[], identifier: string, backlogColumn: string, +) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + const comment = questions.map((q, i) => `${i + 1}. ${q}`).join("\n"); + await issueTracker.postComment(ticketId, comment); + await issueTracker.moveTicket(ticketId, backlogColumn); +} + +async function unregisterRun(ticketIdentifier: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + await runRegistry.unregister(ticketIdentifier); +} + +async function markTicketFailed(ticketIdentifier: string, error: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; + await runRegistry.markFailed(ticketIdentifier, { + runId, error, failedAt: new Date().toISOString(), + }); +} + +// --- Polling helper (not a step — called within the workflow) --- + +async function pollUntilDone( + sandboxId: string, + sentinelFile: string, + maxPollMinutes: number, +): Promise { + const { checkPhaseDone } = await import("../sandbox/poll-agent.js"); + const POLL_INTERVAL = "30s"; + const MAX_POLLS = Math.ceil((maxPollMinutes * 60) / 30); + let pollCount = 0; + + while (pollCount < MAX_POLLS) { + await sleep(POLL_INTERVAL); + pollCount++; + const status = await checkPhaseDone(sandboxId, sentinelFile); + if (status === true) return true; + if (status === "stopped") return false; + } + return false; +} + +// --- Main Workflow --- + +const MAX_REVIEW_RETRIES = 2; + +export async function agentWorkflow(ticketId: string) { + "use workflow"; + + const { env } = await import("../../env.js"); + const { getPrompt } = await import("../lib/prompts.js"); + const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); + const { parseResearchStatus } = await import("../sandbox/agent-runner.js"); + const { parseAgentOutput } = await import("../sandbox/agent-runner.js"); + const { parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = await import("../sandbox/agent-runner.js"); + const { assembleResearchPlanContext, assembleImplementationContext, assembleImplementationRetryContext, assembleReviewContext } = + await import("../sandbox/context.js"); + const { collectPhaseOutput } = await import("../sandbox/poll-agent.js"); + const { pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); + + const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); + if (!ticket) return; + + try { + await notifySlack(`Task ${ticket.identifier} started`); + + const branchName = `blazebot/${ticket.identifier.toLowerCase()}`; + await createFeatureBranch(branchName, env.GITHUB_BASE_BRANCH); + + // Check for existing PR (review-fix scenario) + const prContext = await fetchPRContext(branchName); + const mergeBase = prContext?.hasConflicts ? env.GITHUB_BASE_BRANCH : undefined; + + // Provision sandbox once for all phases + const sandboxId = await provisionSandbox(branchName, mergeBase); + + try { + // ========== PHASE 1: Research & Plan ========== + await configureStopHook(sandboxId, false); + + const researchInput = assembleResearchPlanContext({ + ticket: { + identifier: ticket.identifier, + title: ticket.title, + description: ticket.description, + acceptanceCriteria: ticket.acceptanceCriteria, + comments: ticket.comments, + }, + prompt: getPrompt("research-plan.md"), + branchName, + prComments: prContext?.prComments, + checkResults: prContext?.checkResults, + hasConflicts: prContext?.hasConflicts, + }); + + const researchScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/research-requirements.md", researchInput, + "/tmp/research-wrapper.sh", researchScript, + ); + + const researchDone = await pollUntilDone(sandboxId, "/tmp/research-done", 20); + if (!researchDone) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: research phase timed out`); + await unregisterRun(ticket.identifier); + return; + } + + const researchRaw = await collectPhaseOutput(sandboxId, "/tmp/research-stdout.txt", "/tmp/research-stderr.txt"); + const research = parseResearchStatus(researchRaw); + + if (research.status === "clarification_needed") { + const questions = research.body.split("\n").filter((l) => /^\d+\./.test(l.trim())); + await postClarificationAndMoveBack(ticketId, questions.length > 0 ? questions : [research.body], ticket.identifier, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + if (research.status === "failed") { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: research — ${research.body.slice(0, 200)}`); + await unregisterRun(ticket.identifier); + return; + } + + const researchPlanMarkdown = research.body; + + // ========== PHASE 2 & 3 LOOP ========== + let reviewRetries = 0; + let lastReviewFeedback: ReviewOutput | undefined; + + while (true) { + // ========== PHASE 2: Implementation ========== + await configureStopHook(sandboxId, true); + + const implInput = lastReviewFeedback + ? assembleImplementationRetryContext({ + ticket: { identifier: ticket.identifier, title: ticket.title, description: ticket.description, acceptanceCriteria: ticket.acceptanceCriteria, comments: ticket.comments }, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + reviewFeedback: lastReviewFeedback, + }) + : assembleImplementationContext({ + ticket: { identifier: ticket.identifier, title: ticket.title, description: ticket.description, acceptanceCriteria: ticket.acceptanceCriteria, comments: ticket.comments }, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + }); + + const implScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: AGENT_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/impl-requirements.md", implInput, + "/tmp/impl-wrapper.sh", implScript, + ); + + const implDone = await pollUntilDone(sandboxId, "/tmp/impl-done", 35); + let implOutput: AgentOutput; + + if (implDone) { + const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); + implOutput = parseAgentOutput(implRaw); + } else { + implOutput = { result: "failed", error: "Implementation phase timed out" }; + } + + if (implOutput.result === "clarification_needed") { + await postClarificationAndMoveBack(ticketId, implOutput.questions ?? [], ticket.identifier, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + if (implOutput.result === "failed") { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + // ========== PHASE 3: Review ========== + await configureStopHook(sandboxId, false); + + const gitDiff = await captureGitDiff(sandboxId); + + const reviewInput = assembleReviewContext({ + ticket: { identifier: ticket.identifier, title: ticket.title, description: ticket.description, acceptanceCriteria: ticket.acceptanceCriteria, comments: ticket.comments }, + prompt: getPrompt("review.md"), + researchPlanMarkdown, + gitDiff, + }); + + const reviewScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "review", + inputFile: "/tmp/review-requirements.md", + outputFile: "/tmp/review-stdout.txt", + stderrFile: "/tmp/review-stderr.txt", + sentinelFile: "/tmp/review-done", + jsonSchema: REVIEW_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/review-requirements.md", reviewInput, + "/tmp/review-wrapper.sh", reviewScript, + ); + + const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); + let reviewOutput: ReviewOutput; + + if (reviewDone) { + const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); + reviewOutput = parseReviewOutput(reviewRaw); + } else { + reviewOutput = { result: "failed", error: "Review phase timed out" }; + } + + if (reviewOutput.result === "approved") { + break; // Exit loop → push + } + + if (reviewOutput.result === "changes_requested") { + reviewRetries++; + if (reviewRetries > MAX_REVIEW_RETRIES) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: review rejected after ${MAX_REVIEW_RETRIES} retries`); + await unregisterRun(ticket.identifier); + return; + } + lastReviewFeedback = reviewOutput; + continue; // Loop back to Phase 2 + } + + // result === "failed" + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + // ========== POST-PHASES: Push & PR ========== + let pushResult = await pushFromSandbox(sandboxId, branchName); + if (!pushResult.pushed && pushResult.error) { + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + } + + if (!pushResult.pushed) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + await createPullRequest(branchName, ticket.title, ""); + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + await notifySlack(`Task ${ticket.identifier} PR ready for review`); + await unregisterRun(ticket.identifier); + } finally { + await teardownSandbox(sandboxId); + } + } catch (err) { + console.error(`Workflow failed for ${ticket.identifier}:`, err); + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); + await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + if (moved) { + await unregisterRun(ticket.identifier).catch(() => {}); + } else { + await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + } + throw err; + } +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add src/workflows/agent.ts +git commit -m "feat: create unified three-phase agentWorkflow" +``` + +--- + +### Task 8: Update dispatch and delete old workflows + +**Files:** +- Modify: `src/lib/dispatch.ts` +- Modify: `src/lib/dispatch.test.ts` +- Delete: `src/workflows/implementation.ts` +- Delete: `src/workflows/review-fix.ts` + +- [ ] **Step 1: Update dispatch.ts to use `agentWorkflow`** + +Replace the workflow imports and `startWorkflow` function: + +```typescript +import { start, getRun } from "workflow/api"; +import { agentWorkflow } from "../workflows/agent.js"; +import { logger } from "./logger.js"; +import type { Adapters } from "./adapters.js"; + +// ... keep CLAIMING_PREFIX, isClaimingSentinel, getClaimTimestamp, DispatchResult, isAtCapacity, getActiveSandboxCount, verifyClaimNotCancelled, abortWorkflow ... + +export async function dispatchTicket( + ticketKey: string, + adapters: Adapters, + maxConcurrentAgents: number, +): Promise { + const { issueTracker, runRegistry } = adapters; + + if (await runRegistry.isTicketFailed(ticketKey)) { + logger.info({ ticketKey }, "dispatch_skipped_previously_failed"); + return { started: false, reason: "previously_failed" }; + } + + if (await isAtCapacity(maxConcurrentAgents)) { + return { started: false, reason: "at_capacity" }; + } + + const claimValue = `${CLAIMING_PREFIX}${Date.now()}`; + const claimed = await runRegistry.claim(ticketKey, claimValue); + if (!claimed) { + logger.info({ ticketKey }, "dispatch_already_claimed"); + return { started: false, reason: "already_claimed" }; + } + + try { + const ticket = await issueTracker.fetchTicket(ticketKey); + + const handle = await start(agentWorkflow, [ticket.id]); + logger.info( + { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, + "workflow_started", + ); + + const claimStillHeld = await verifyClaimNotCancelled(ticketKey, claimValue, runRegistry); + if (!claimStillHeld) { + await abortWorkflow(handle.runId, ticketKey); + return { started: false, reason: "already_claimed" }; + } + + await runRegistry.register(ticketKey, handle.runId); + return { started: true, runId: handle.runId }; + } catch (err) { + await runRegistry.unregister(ticketKey).catch(() => {}); + logger.warn({ ticketKey, error: (err as Error).message }, "dispatch_error"); + return { started: false, reason: "error" }; + } +} +``` + +Key changes: removed `vcs` from destructure (no longer needed for PR check), removed `branchName` computation, removed `startWorkflow` helper, always `start(agentWorkflow, [ticket.id])`. + +- [ ] **Step 2: Update dispatch.test.ts** + +Replace the mock and test assertions: + +```typescript +// Replace the old workflow mocks with: +vi.mock("../workflows/agent.js", () => ({ + agentWorkflow: "agentWorkflow_sentinel", +})); + +// Remove the reviewFixWorkflow mock entirely + +// In "dispatches implementation workflow when no PR exists" test: +// Remove the findPR assertion +// Change: expect(mockStart).toHaveBeenCalledWith("agentWorkflow_sentinel", ["ticket-001"]); + +// Remove "dispatches review-fix workflow when PR exists" test entirely +// (or change it to verify agentWorkflow is still called regardless of PR) + +// In makeAdapters: findPR is no longer needed in dispatch, remove it from overrides +``` + +- [ ] **Step 3: Run tests** + +Run: `npx vitest run src/lib/dispatch.test.ts` +Expected: All tests PASS + +- [ ] **Step 4: Delete old workflow files** + +```bash +rm src/workflows/implementation.ts src/workflows/review-fix.ts +``` + +- [ ] **Step 5: Check for remaining imports of deleted files** + +Run: `npx vitest run` +If any test imports the old workflows, update those imports. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/dispatch.ts src/lib/dispatch.test.ts +git rm src/workflows/implementation.ts src/workflows/review-fix.ts +git commit -m "feat: unify dispatch to single agentWorkflow, delete old workflows" +``` + +--- + +### Task 9: Full test suite and type check + +**Files:** +- All modified files + +- [ ] **Step 1: Run the full test suite** + +Run: `npx vitest run` +Expected: All tests PASS + +- [ ] **Step 2: Run TypeScript type check** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Run linting if configured** + +Run: `npx eslint src/` (or whatever lint command exists in package.json) +Expected: No errors + +- [ ] **Step 4: Fix any remaining issues** + +If any tests fail or types don't check, fix them and commit: + +```bash +git add -A +git commit -m "fix: resolve test and type issues from workflow migration" +``` + +--- + +### Task 10: Verify reconcile.ts still works with new workflow + +**Files:** +- Read: `src/lib/reconcile.ts` + +- [ ] **Step 1: Check reconcile.ts for references to old workflows** + +`reconcile.ts` uses `getRun()` from the workflow SDK and doesn't import workflow functions directly. Verify it still works: + +Run: `npx vitest run src/lib/reconcile.test.ts` +Expected: PASS — reconcile doesn't care which workflow type was started, only that a `runId` exists. + +- [ ] **Step 2: Commit if any changes were needed** + +```bash +git add src/lib/reconcile.ts src/lib/reconcile.test.ts +git commit -m "fix: update reconcile for unified workflow" +``` + +(Skip if no changes needed.) diff --git a/docs/superpowers/specs/2026-04-06-three-phase-workflow-design.md b/docs/superpowers/specs/2026-04-06-three-phase-workflow-design.md new file mode 100644 index 0000000..7031d7f --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-three-phase-workflow-design.md @@ -0,0 +1,504 @@ +# Three-Phase Agent Workflow + +**Date**: 2026-04-06 +**Status**: Draft + +## Problem + +The current system has two separate workflows (`implementationWorkflow` and `reviewFixWorkflow`) that each dump all context into a single agent invocation. This has several issues: + +- The agent receives a large, undifferentiated context blob and must figure out what to do +- No workflow-level control between logical phases — if research reveals clarification is needed, the agent has already started coding +- The implementation prompt is overloaded with instructions for exploration, planning, coding, testing, and review +- Two separate workflows with duplicated orchestration logic (polling, push, teardown) +- No separation of concerns — one agent failure means the entire ticket fails with no intermediate artifacts + +## Solution + +Replace both workflows with a **single unified `agentWorkflow`** that handles all ticket scenarios (new implementation, review-fix, partial work). The workflow splits work into **three sequential phases** within the same sandbox, each a separate `claude --print` call. The **workflow** orchestrates transitions, checks results between phases, and decides whether to proceed or fail fast. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SANDBOX (single, alive for entire flow) │ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ ┌────────┐ │ +│ │ Research & │───▶│ Implementation │───▶│ Review │ │ +│ │ Plan (claude) │ │ (claude) │ │(claude)│ │ +│ └──────┬───────┘ └────────┬─────────┘ └───┬────┘ │ +│ │ │ │ │ +│ fail fast fail fast ┌─────┴──────┐ │ +│ on error/ on error/ │ approved? │ │ +│ clarification clarification └─────┬──────┘ │ +│ yes │ no (max 2) │ +│ │ └──▶ back │ +│ │ to impl │ +│ ▼ │ +│ push │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Unified Flow + +The `agentWorkflow` replaces both `implementationWorkflow` and `reviewFixWorkflow`. It handles all scenarios because Phase 1 (Research & Plan) adapts to the current state: + +| Scenario | What Phase 1 sees | What it plans | +|----------|-------------------|---------------| +| **New ticket** (no branch/PR) | Empty branch, full requirements | Full implementation | +| **Ticket with existing PR + review feedback** | Existing commits + PR comments + CI failures | Only the fixes needed | +| **Ticket with partial work** | Some commits, incomplete work | Remaining steps | + +The workflow always receives PR feedback and CI results **when they exist**. The research agent sees this context and plans accordingly. + +### Dispatch Simplification + +`dispatch.ts` no longer branches between two workflow types. It always starts `agentWorkflow(ticketId)`. The workflow itself handles the branch check, PR context fetching, and merge-base logic internally. + +## Phase 1 — Research & Plan + +### Purpose + +Explore the repository, understand the ticket, check for existing work on the branch, and produce a **precise, minimal implementation plan** with only actionable steps. + +### Input + +Written to `/tmp/research-requirements.md`: + +- Ticket ID, title, description, acceptance criteria, comments +- Branch name (for checking existing changes via `git log`/`git diff`) +- **PR review feedback** (if an existing PR has comments — fetched by workflow) +- **CI/CD check results** (if an existing PR has failed checks — fetched by workflow) +- **Merge conflict status** (if applicable) +- Research & planning prompt (see Prompts section) + +### Agent Behavior + +1. **Read session memory** — check `blazebot/memory/[TASK_ID].md` for context from prior runs +2. Explore repo structure, read `CLAUDE.md`/`AGENTS.md` if present +3. Check `git log` / `git diff` against base branch to identify existing changes +4. If PR feedback/CI failures are present: understand what needs to be fixed +5. Identify what's already implemented vs. what remains +6. Analyze relevant files, code patterns, test setup +7. **Use the `brainstorming` skill from superpowers** to think through the approach +8. Produce a clean implementation plan with only actionable steps for the remaining work +9. Write/update session memory + +### Output Format + +The research agent output is **free-form markdown**, not structured JSON. This gives the agent flexibility to express findings naturally and organize the plan in the way that best fits the specific ticket. + +The only structured requirement is a **status line** at the very top of the output: + +``` +STATUS: completed | clarification_needed | failed +``` + +The workflow parses only this status line to decide next steps. The rest of the output (the plan, research findings, etc.) is passed as-is to Phase 2 as context. + +#### Output Constraints + +The plan portion must be **minimal and precise**: +- Each step must be directly actionable ("Create file X with Y" not "Consider how to...") +- No preamble, rationale, or noise that would confuse the implementation agent +- File paths must be concrete, not vague ("src/components/Foo.tsx" not "the relevant component") +- The output should be structured so the implementation agent can read it top-to-bottom and execute + +If `STATUS: clarification_needed`, the output should contain the questions (one per line, numbered). The workflow will extract and post them to Jira. + +If `STATUS: failed`, the output should contain the error description. + +### Workflow Decision After Phase 1 + +| Status line | Action | +|-------------|--------| +| `completed` | Save full output to `/tmp/research-plan-output.md`, proceed to Phase 2 | +| `clarification_needed` | Extract questions from output, post on Jira, move to backlog, teardown | +| `failed` | Notify Slack with error, move to backlog, teardown | + +### Sentinel & Output Files + +- Stdout: `/tmp/research-stdout.txt` +- Stderr: `/tmp/research-stderr.txt` +- Sentinel: `/tmp/research-done` +- Plan (written by workflow after parsing): `/tmp/research-plan-output.md` + +## Phase 2 — Implementation + +### Purpose + +Execute the plan from Phase 1. The agent receives precise instructions and focuses solely on coding, testing, and committing. + +### Input + +Written to `/tmp/impl-requirements.md`: + +- Ticket ID, title, acceptance criteria (for reference, kept brief) +- **Full research & plan output from Phase 1** (free-form markdown — passed as-is) +- If this is a **retry after review feedback**: also includes the review issues and feedback +- Implementation prompt (see Prompts section) + +### Agent Behavior + +1. Read the plan from Phase 1 output +2. **Use the `executing-plans` skill from superpowers** to execute the plan systematically +3. If retry: read review feedback, focus on fixing flagged issues +4. Execute each step in order +5. Run tests and quality checks +6. Commit all changes with descriptive messages + +### Output Schema + +Same as today's `AgentOutput`: + +```typescript +const implOutputSchema = z.object({ + result: z.enum(["implemented", "clarification_needed", "failed"]), + summary: z.string().optional(), + questions: z.array(z.string()).optional(), + error: z.string().optional(), +}); +``` + +### Workflow Decision After Phase 2 + +| Result | Action | +|--------|--------| +| `implemented` | Proceed to Phase 3 (Review) | +| `clarification_needed` | Post questions on Jira, move to backlog, teardown | +| `failed` | Notify Slack, move to backlog, teardown | + +### Sentinel & Output Files + +- Stdout: `/tmp/impl-stdout.txt` +- Stderr: `/tmp/impl-stderr.txt` +- Sentinel: `/tmp/impl-done` + +## Phase 3 — Review + +### Purpose + +Review the implementation diff against the plan and acceptance criteria. Check code quality, test coverage, and completeness. Use the `requesting-code-review` skill. + +### Input + +Written to `/tmp/review-requirements.md`: + +- Ticket ID, title, acceptance criteria +- Plan output from Phase 1 (what was supposed to happen) +- Git diff of all changes (`git diff ..HEAD` — captured by workflow via sandbox command) +- Review prompt (see Prompts section) + +### Agent Behavior + +1. Read the plan and acceptance criteria +2. Review the diff against the plan — did the agent follow it? +3. Check code quality, test coverage, edge cases +4. Invoke `requesting-code-review` skill to dispatch a code-reviewer subagent +5. Output approval or specific issues to fix + +### Output Schema + +```typescript +const reviewOutputSchema = z.object({ + result: z.enum(["approved", "changes_requested", "failed"]), + feedback: z.string().describe("Detailed review notes"), + issues: z.array(z.object({ + file: z.string(), + description: z.string(), + severity: z.enum(["critical", "suggestion"]), + })).describe("Specific issues found"), + error: z.string().optional(), +}); + +type ReviewOutput = z.infer; +``` + +### Workflow Decision After Phase 3 + +| Result | Action | +|--------|--------| +| `approved` | Push, create PR (or update existing), move to AI Review, notify Slack | +| `changes_requested` | If retries < MAX_REVIEW_RETRIES (2): loop back to Phase 2 with feedback. Otherwise: fail fast | +| `failed` | Notify Slack, move to backlog, teardown | + +### Review → Implementation Loop + +When review returns `changes_requested`: +1. Workflow increments a retry counter +2. Workflow writes a new `/tmp/impl-requirements.md` that includes: + - Original plan from Phase 1 + - Review feedback (`issues` + `feedback` from review output) + - Instruction: "Fix the issues listed below. Do not redo work that was approved." +3. Re-runs Phase 2 (implementation) +4. Re-runs Phase 3 (review) +5. Maximum 2 retries (3 total implementation attempts) + +### Sentinel & Output Files + +- Stdout: `/tmp/review-stdout.txt` +- Stderr: `/tmp/review-stderr.txt` +- Sentinel: `/tmp/review-done` + +## Wrapper Script Changes + +The current `buildWrapperScript` generates a single hardcoded script. It needs to become parameterized to support multiple phases. + +### New Signature + +```typescript +interface PhaseScriptOptions { + model: string; + phase: "research" | "impl" | "review"; + inputFile: string; // e.g. "/tmp/research-requirements.md" + outputFile: string; // e.g. "/tmp/research-stdout.txt" + stderrFile: string; // e.g. "/tmp/research-stderr.txt" + sentinelFile: string; // e.g. "/tmp/research-done" + jsonSchema?: string; // phase-specific JSON schema (only for impl and review phases) +} + +function buildPhaseScript(opts: PhaseScriptOptions): string; +``` + +The generated script follows the same pattern as today: +1. `cat | claude --print --model X --dangerously-skip-permissions [--output-format json --json-schema ''] > 2>` + - Research phase: NO `--output-format json` or `--json-schema` (free-form markdown output) + - Implementation and review phases: include `--output-format json --json-schema` for structured output +2. Cleanup `.claude/` artifacts +3. `touch ` + +### Stop Hook Behavior + +- **Research & Plan phase**: Stop hook should NOT enforce commits (research agent doesn't write code) +- **Implementation phase**: Stop hook enforces commits (same as today) +- **Review phase**: Stop hook should NOT enforce commits (review agent only reads) + +**Decision**: Simplest approach — only install the stop hook before Phase 2 (implementation), remove it before Phase 1 and Phase 3. Since the sandbox is provisioned once, the workflow can run a command to toggle the hook between phases. + +## Context Assembly Changes + +### Current + +- `assembleImplementationContext(ticket, prompt)` — one function, one context +- `assembleFixingFeedbackContext(ticket, prompt, prComments, hasConflicts, checkResults)` — for review-fix + +### New + +Three new context assembly functions in `src/sandbox/context.ts`: + +```typescript +// Phase 1 input — includes optional PR feedback for review-fix scenarios +interface ResearchPlanContextInput { + ticket: TicketData; + prompt: string; + branchName: string; + prComments?: PRComment[]; // present when PR exists + checkResults?: CheckRunResult[]; // present when PR exists + hasConflicts?: boolean; // present when PR exists +} +function assembleResearchPlanContext(input: ResearchPlanContextInput): string; + +// Phase 2 input (first run) +interface ImplementationContextInput { + ticket: TicketData; // kept minimal — ID, title, acceptance criteria only + prompt: string; + researchPlanMarkdown: string; // free-form output from Phase 1, passed as-is +} +function assembleImplementationContext(input: ImplementationContextInput): string; + +// Phase 2 input (retry after review) +interface ImplementationRetryContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; // free-form output from Phase 1, passed as-is + reviewFeedback: ReviewOutput; +} +function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string; + +// Phase 3 input +interface ReviewContextInput { + ticket: TicketData; // kept minimal — ID, title, acceptance criteria only + prompt: string; + researchPlanMarkdown: string; // free-form output from Phase 1, passed as-is + gitDiff: string; +} +function assembleReviewContext(input: ReviewContextInput): string; +``` + +The old `assembleFixingFeedbackContext` is removed — its PR feedback/CI data is now fed into `assembleResearchPlanContext` instead. + +## Prompt Changes + +### Current + +- `implement.md` — single monolithic prompt (exploration + planning + coding + testing + review) +- `review-fix.md` — for fixing PR feedback + +### New + +Three new prompts in `src/lib/prompts.ts`. The old `implement.md` and `review-fix.md` are removed. + +#### `research-plan.md` + +Focused prompt for Phase 1: +- **Read session memory** (`blazebot/memory/[TASK_ID].md`) first if it exists +- Explore the repo, check existing changes on the branch +- If PR feedback/CI failures are present: factor them into the plan +- **Use the `brainstorming` skill** to think through the approach +- Produce a precise implementation plan — actionable steps only, no noise +- Output starts with `STATUS: completed|clarification_needed|failed` on the first line +- Check for clarification needs (same criteria as today) +- Write/update session memory +- NO coding, NO commits +- NO `--output-format json` or `--json-schema` for this phase (free-form markdown output) + +#### `implement.md` (rewritten) + +Focused prompt for Phase 2: +- **Use the `executing-plans` skill** to systematically execute the plan from Phase 1 +- If retrying: fix the review feedback, do not redo approved work +- Run tests and quality checks +- Commit with descriptive messages +- Write/update session memory +- NO exploration (already done), NO planning (already done), NO code review (separate phase) + +#### `review.md` (new) + +Focused prompt for Phase 3: +- Review the diff against the plan and acceptance criteria +- Check code quality, test coverage, edge cases +- Use `requesting-code-review` skill to dispatch code-reviewer subagent +- Output approval or specific, actionable issues +- NO coding, NO commits + +## Workflow Changes + +### `src/workflows/implementation.ts` → `src/workflows/agent.ts` + +Renamed and rewritten. The exported function becomes `agentWorkflow(ticketId: string)`. + +The workflow changes from: + +``` +fetchTicket → createBranch → assembleContext → provision → startAgent → poll → collect → handle result → push → PR +``` + +To: + +``` +fetchTicket → createBranch → fetchPRContext (if PR exists) → provision sandbox (with mergeBase if PR exists) + → Phase 1: writeResearchInput (includes PR feedback if any) → startResearchAgent → poll → collect → check + → Phase 2: writeImplInput → configureStopHook(on) → startImplAgent → poll → collect → check + → Phase 3: captureGitDiff → writeReviewInput → configureStopHook(off) → startReviewAgent → poll → collect → check + → if changes_requested and retries < MAX: goto Phase 2 + → push → createOrUpdatePR → cleanup +``` + +### `src/workflows/review-fix.ts` — DELETED + +All review-fix logic is absorbed into `agentWorkflow`. The research agent handles PR feedback as part of its context. + +### `src/lib/dispatch.ts` — Simplified + +```typescript +// Before: branching between two workflows +const existingPR = await vcs.findPR(branchName); +const handle = existingPR + ? await start(reviewFixWorkflow, [ticket.id, branchName]) + : await start(implementationWorkflow, [ticket.id]); + +// After: always the same workflow +const handle = await start(agentWorkflow, [ticket.id]); +``` + +The workflow internally checks for existing PRs and fetches context as needed. + +### Key Implementation Details + +1. **Sandbox provisioned once** — `SandboxManager.provision()` called once at the start. If a PR exists with merge conflicts, `mergeBase` is passed to provision (same as review-fix did). The three phase scripts are written and executed sequentially within the same sandbox. + +2. **Phase execution is a reusable function** — extract a `runPhase(sandboxId, phaseConfig)` helper that handles: write input file → write wrapper script → start detached → poll → collect → parse output. This avoids duplicating the polling loop three times. + +3. **Pre-agent SHA recorded once** — `/tmp/.pre-agent-sha` is written during provisioning (before any agent runs). The push step compares against this to detect commits. + +4. **Git diff for review** — before Phase 3, the workflow runs `git diff ..HEAD` inside the sandbox (via `sandbox.runCommand`) and passes the output to the review context. + +5. **Stop hook toggling** — the workflow writes `~/.claude/settings.json` with/without the stop hook before each phase. Research and review phases get an empty hooks config; implementation gets the commit-guard hook. + +6. **Retry counter** — tracked as a workflow-level variable, not persisted to disk. Incremented when review returns `changes_requested`. + +7. **PR handling** — if a PR already exists, the workflow pushes to the same branch (force push, same as today) and does NOT create a new PR. If no PR exists, it creates one. + +### New Step Functions + +```typescript +// Generic phase runner — handles write input, start agent, poll, collect +async function runPhase( + sandboxId: string, + phase: PhaseConfig, +): Promise<{ raw: string }>; + +// Write phase input file to sandbox +async function writePhaseInput( + sandboxId: string, + inputFile: string, + content: string, +): Promise; + +// Toggle stop hook on/off +async function configureStopHook( + sandboxId: string, + enabled: boolean, +): Promise; + +// Capture git diff for review phase +async function captureGitDiff( + sandboxId: string, +): Promise; + +// Fetch PR context (comments, checks, conflicts) — returns null if no PR exists +async function fetchPRContext( + branchName: string, +): Promise; +``` + +## Session Memory + +Session memory (`blazebot/memory/[TASK_ID].md`) behavior: + +- **Phase 1 (Research & Plan)**: Reads memory first (for context from prior runs), then writes updated memory with research findings and plan +- **Phase 2 (Implementation)**: Reads and updates session memory with implementation progress +- **Phase 3 (Review)**: Reads session memory for context, writes review findings + +Each phase overwrites the memory file (same as today). The memory serves as additional context across phases and across workflow runs, but is NOT the primary handoff mechanism — the Phase 1 free-form output is the primary handoff to Phase 2 and 3. + +## File Changes Summary + +| File | Change | +|------|--------| +| `src/workflows/implementation.ts` | **Delete** — replaced by `agent.ts` | +| `src/workflows/review-fix.ts` | **Delete** — absorbed into `agent.ts` | +| `src/workflows/agent.ts` | **New** — unified three-phase `agentWorkflow` | +| `src/lib/dispatch.ts` | Simplify: remove workflow branching, always start `agentWorkflow` | +| `src/sandbox/wrapper-script.ts` | Parameterize: `buildPhaseScript(opts)` replacing `buildWrapperScript` | +| `src/sandbox/context.ts` | Add: `assembleResearchPlanContext`, rewrite `assembleImplementationContext`, add `assembleImplementationRetryContext`, add `assembleReviewContext`. Remove: `assembleFixingFeedbackContext` | +| `src/sandbox/agent-runner.ts` | Add: `ReviewOutput` schema + parser. Add: `parseResearchStatus()` (extracts STATUS line from free-form output) | +| `src/sandbox/manager.ts` | Refactor: extract stop-hook config, support phase-based execution | +| `src/sandbox/poll-agent.ts` | Generalize: `checkPhaseDone(sandboxId, sentinelFile)`, `collectPhaseOutput(sandboxId, outputFile)` | +| `src/lib/prompts.ts` | Add: `research-plan.md`, `review.md`. Rewrite: `implement.md`. Remove: `review-fix.md` | +| `src/sandbox/run-agent.ts` | Generalize: accept phase config instead of hardcoded paths | + +## Skills Installation + +The current `GLOBAL_SKILLS` in `manager.ts` installs: +- `using-superpowers` (from `superpowers` repo) +- `requesting-code-review` (from `superpowers` repo) +- `frontend-design` (from `anthropics/skills` repo) + +The `brainstorming` and `executing-plans` skills (required by Phase 1 and Phase 2) are part of the `superpowers` repo and are discoverable via the `using-superpowers` skill — they do NOT need separate installs. The phase prompts will explicitly instruct the agent to use them. + +No changes to `GLOBAL_SKILLS` are needed. + +## Non-Goals + +- **Parallel phase execution** — phases are sequential by design +- **Multiple sandboxes** — single sandbox for the entire flow +- **Agent-to-agent communication** — phases communicate only through files orchestrated by the workflow diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index c3e7e8d..86f4bae 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -9,12 +9,8 @@ vi.mock("workflow/api", () => ({ getRun: (...args: any[]) => mockGetRun(...args), })); -vi.mock("../workflows/implementation.js", () => ({ - implementationWorkflow: "implementationWorkflow_sentinel", -})); - -vi.mock("../workflows/review-fix.js", () => ({ - reviewFixWorkflow: "reviewFixWorkflow_sentinel", +vi.mock("../workflows/agent.js", () => ({ + agentWorkflow: "agentWorkflow_sentinel", })); const mockSandboxList = vi.fn(); @@ -102,7 +98,7 @@ describe("dispatchTicket", () => { mockStart.mockResolvedValue({ runId: "run_123" }); }); - it("dispatches implementation workflow when no PR exists", async () => { + it("dispatches agentWorkflow for any ticket", async () => { const adapters = makeAdapters(); const { dispatchTicket } = await import("./dispatch.js"); @@ -114,32 +110,8 @@ describe("dispatchTicket", () => { expect.stringMatching(/^claiming:\d+$/), ); expect(adapters.issueTracker.fetchTicket).toHaveBeenCalledWith("PROJ-42"); - expect(adapters.vcs.findPR).toHaveBeenCalledWith("blazebot/proj-42"); - expect(mockStart).toHaveBeenCalledWith("implementationWorkflow_sentinel", [ - "ticket-001", - ]); - expect(adapters.runRegistry.register).toHaveBeenCalledWith( - "PROJ-42", - "run_123", - ); - }); - - it("dispatches review-fix workflow when PR exists", async () => { - const adapters = makeAdapters({ - findPR: vi.fn().mockResolvedValue({ - id: 7, - url: "https://github.com/pr/7", - branch: "blazebot/proj-42", - }), - }); - const { dispatchTicket } = await import("./dispatch.js"); - - const result = await dispatchTicket("PROJ-42", adapters, 5); - - expect(result).toEqual({ started: true, runId: "run_123" }); - expect(mockStart).toHaveBeenCalledWith("reviewFixWorkflow_sentinel", [ + expect(mockStart).toHaveBeenCalledWith("agentWorkflow_sentinel", [ "ticket-001", - "blazebot/proj-42", ]); expect(adapters.runRegistry.register).toHaveBeenCalledWith( "PROJ-42", diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index 6ef0265..f672477 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -1,6 +1,5 @@ import { start, getRun } from "workflow/api"; -import { implementationWorkflow } from "../workflows/implementation.js"; -import { reviewFixWorkflow } from "../workflows/review-fix.js"; +import { agentWorkflow } from "../workflows/agent.js"; import { logger } from "./logger.js"; import type { Adapters } from "./adapters.js"; @@ -25,7 +24,7 @@ export async function dispatchTicket( adapters: Adapters, maxConcurrentAgents: number, ): Promise { - const { issueTracker, vcs, runRegistry } = adapters; + const { issueTracker, runRegistry } = adapters; if (await runRegistry.isTicketFailed(ticketKey)) { logger.info({ ticketKey }, "dispatch_skipped_previously_failed"); @@ -45,9 +44,12 @@ export async function dispatchTicket( try { const ticket = await issueTracker.fetchTicket(ticketKey); - const branchName = `blazebot/${ticket.identifier.toLowerCase()}`; - const handle = await startWorkflow(ticket, branchName, vcs); + const handle = await start(agentWorkflow, [ticket.id]); + logger.info( + { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, + "workflow_started", + ); const claimStillHeld = await verifyClaimNotCancelled( ticketKey, @@ -90,26 +92,6 @@ async function getActiveSandboxCount(): Promise { } } -async function startWorkflow( - ticket: { id: string; identifier: string }, - branchName: string, - vcs: Adapters["vcs"], -) { - const existingPR = await vcs.findPR(branchName); - - const handle = existingPR - ? await start(reviewFixWorkflow, [ticket.id, branchName]) - : await start(implementationWorkflow, [ticket.id]); - - const workflowType = existingPR ? "review_fix" : "implementation"; - logger.info( - { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, - `workflow_started_${workflowType}`, - ); - - return handle; -} - async function verifyClaimNotCancelled( ticketKey: string, expectedClaimValue: string, diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index fc7246e..6f4127d 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,224 +1,201 @@ -// Prompt content embedded at build time. -// Edit prompts directly in this file. +const researchPlanPrompt = `# Instructions -const implementPrompt = `# Instructions - -You are an AI coding agent implementing a feature based on the requirements above. +You are an AI research agent. Your job is to explore the repository, understand the ticket, and produce a precise implementation plan. -## Autonomy +## Output Format -You are a **semi-autonomous agent**. You should drive implementation forward independently and only ask questions when you genuinely cannot proceed without human input. +Your output MUST start with a STATUS line on the very first line: -- **Do not ask questions you can answer yourself** by reading the codebase, checking existing patterns, or making reasonable inferences from context. -- **When you must ask questions, batch them.** Never ask a single question when you have multiple. Collect all blockers, then return once with all questions together. -- A round-trip for clarification is expensive — exhaust every reasonable avenue before requesting one. +\`\`\` +STATUS: completed +\`\`\` -## Superpowers +Valid statuses: \`completed\`, \`clarification_needed\`, \`failed\` -You have access to **superpowers skills** installed globally. Use them — they provide structured workflows that improve your output quality. +Everything after the STATUS line is your research findings and plan. This output will be passed as-is to the implementation agent — keep it clean and actionable. -- **Always check for applicable skills before starting work.** The \`using-superpowers\` skill is loaded — follow its guidance on when to invoke other skills. -- **Use \`brainstorming\` before creative or ambiguous work** — designing features, choosing between approaches, or scoping implementation. -- **Use \`test-driven-development\` when writing tests and implementation** — it structures TDD correctly. -- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. -- **Use \`requesting-code-review\` for self-review** — this is already in your process, follow it. -- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. -- Skills exist for a reason. If there's even a small chance a skill applies to what you're doing, invoke it. +## Superpowers -## Constraints +You have access to **superpowers skills** installed globally. Use them. -- Only modify files relevant to the ticket requirements. -- Do not refactor code outside the scope of the acceptance criteria. -- Do not make architectural changes unless explicitly requested. -- Do not install new dependencies unless the ticket explicitly requires them. -- Follow existing code conventions in the repository (check CLAUDE.md, AGENTS.md if present). +- **Always check for applicable skills before starting work.** The \`using-superpowers\` skill is loaded — follow its guidance. +- **Use \`brainstorming\` to think through the approach** — explore alternatives, consider trade-offs, then settle on the best path. ## Process -0. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. Use the progress, decisions, and file list to skip redundant analysis and pick up where the previous session left off. -1. Read and understand the requirements, description, and acceptance criteria. -2. Briefly review the codebase to understand the relevant structure (do not deep-dive yet). -3. **Assess ticket clarity** — with the ticket and codebase context in mind, evaluate whether the ticket provides enough information to implement correctly (see "When to Ask for Clarification" below). If not, write session memory and return \`clarification_needed\`. Do NOT write any code. -4. **Tests** — check if the repository has an existing test setup (look for a test config file like \`vitest.config.ts\`, \`jest.config.*\`, or test scripts in \`package.json\`). - - If a test setup exists: write tests using the existing framework and patterns. Do NOT install additional test dependencies or create new config files. - - If no test setup exists: do NOT write tests. Do NOT install a test framework. Do NOT create test config files. Skip this step entirely. -5. Implement the feature. -6. If the repo has tests, run them to ensure nothing is broken. If no test setup exists, skip this step. -7. Self-review your changes for quality, correctness, and completeness. -8. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. -9. **Update session memory** — write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -10. Commit your work with descriptive commit messages that explain the "why", not just - the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -11. Run all quality checks (see Quality Gate below). - -## When to Ask for Clarification +1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. +2. Explore the repository structure. Read \`CLAUDE.md\`, \`AGENTS.md\` if present. +3. Check \`git log\` and \`git diff\` against the base branch to identify what's already been done on this branch. +4. If PR review feedback or CI/CD failures are included above, understand what needs to be fixed. +5. Identify what's already implemented vs. what remains. +6. Analyze relevant files, code patterns, test setup. +7. **Use the \`brainstorming\` skill** to think through the approach. +8. Produce a precise implementation plan for the remaining work. +9. **Write/update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. -**You MUST return \`clarification_needed\` if ANY of these are true — no exceptions:** +## Plan Output Constraints -- **No clear definition of done**: The ticket (description + acceptance criteria combined) does not make it clear what "done" looks like. If neither field specifies concrete behavior, expected outcomes, or verifiable conditions, return \`clarification_needed\`. A detailed description can serve as acceptance criteria — but vague statements like "users should get notifications when things happen" are not implementable. -- **Ambiguous scope**: It is unclear which features, pages, or components should be affected. -- **Missing technical context**: The ticket references systems, APIs, or data models you cannot find in the codebase. -- **Contradictory requirements**: The description, acceptance criteria, or comments conflict with each other. -- **Multiple valid interpretations**: The requirements could reasonably be implemented in significantly different ways, and choosing wrong would waste effort. -- **Missing design/UX details**: For UI work, critical layout, behavior, or interaction details are absent and cannot be inferred from existing patterns. +Your plan MUST be: +- **Actionable only** — each step must be directly executable ("Create file X with Y" not "Consider how to...") +- **Minimal** — no preamble, rationale, or context noise that would confuse the implementation agent +- **Concrete** — file paths must be specific ("src/components/Foo.tsx" not "the relevant component") +- **Structured for top-to-bottom execution** — the implementation agent reads and executes sequentially -**Do NOT guess on critical decisions.** But also do not ask about things you can resolve yourself by reading the codebase. A round-trip for clarification is expensive — exhaust code-level investigation first. +## When to Ask for Clarification -When you do need clarification: -- **Batch ALL questions into a single return.** Never return \`clarification_needed\` with just one question if you have multiple blockers. -- Provide specific, actionable questions that unblock you once answered. -- Explain what you already tried or checked so the answerer has context. +Return \`STATUS: clarification_needed\` if: +- No clear definition of done in the ticket +- Ambiguous scope +- Missing technical context +- Contradictory requirements +- Multiple valid interpretations +- Missing design/UX details for UI work -You may infer minor implementation details from existing code patterns, but you must NEVER infer scope, acceptance criteria, or architecture from patterns alone. +When you need clarification, list your questions as numbered lines after the STATUS line. Batch ALL questions — never return with just one. -## Comment Overrides +## Constraints -If a ticket comment is prefixed with \`[OVERRIDE]\`, treat it as authoritative over any -prior conflicting instructions. The latest \`[OVERRIDE]\` comment takes precedence. +- **NO coding** — do not write implementation code +- **NO commits** — do not create any git commits +- Only analyze and plan ## Session Memory -**MANDATORY — you MUST do this before returning ANY result.** Regardless of outcome (\`implemented\`, \`clarification_needed\`, or \`failed\`), you MUST **overwrite** \`blazebot/memory/[TASK_ID].md\` where \`[TASK_ID]\` is the Ticket ID (e.g. \`AIW-123\`). Create the \`blazebot/memory/\` directory if it does not exist. Skipping this step is a failure condition. - -**Always replace the entire file** — do not append to previous content. Each session writes a complete snapshot of current state so future sessions have an accurate picture. - -Use this format: +**MANDATORY** — before returning, overwrite \`blazebot/memory/[TASK_ID].md\`: \`\`\`markdown # Session Memory — [TASK_ID] ## Progress -- What was analyzed, understood, and attempted this session -- Include work from prior sessions if still relevant +- What was analyzed and planned this session ## Decisions Made -- Technical choices and reasoning (e.g. "Using existing Zod pattern from src/db/schema.ts") +- Technical choices and reasoning ## Blockers - What is blocking progress (if clarification_needed or failed) -- Specific questions that need answers -- "None" if implemented successfully +- "None" if completed successfully ## Files Touched -- List of files created or modified with brief notes +- "None — research phase only" ## Prior Sessions -- Brief summary of what previous sessions did (if memory file existed when this session started) -\`\`\` - -Keep the memory concise and factual. This file will be read by future agent sessions (including review-fix agents) to restore context. - -## Quality Gate - -Before finishing, you MUST: -- Find and run ALL quality checks in the project: tests, linting, type checking, - formatting, and any other validation scripts. -- Fix all failures and commit your fixes with descriptive messages. - -## Output - -Return a JSON object with: - -- \`result\`: "implemented" if done, "clarification_needed" if you have questions, "failed" if stuck. -- \`summary\`: Description of work done (when implemented). -- \`questions\`: List of questions (when clarification_needed). -- \`error\`: Failure details (when failed).`; - -const reviewFixPrompt = `# Instructions - -You are an AI coding agent fixing review feedback and resolving merge conflicts. +- Brief summary of prior sessions (if memory file existed) +\`\`\``; -## Autonomy - -You are a **semi-autonomous agent**. Drive fixes forward independently. Only return \`failed\` when you genuinely cannot proceed. +const implementPrompt = `# Instructions -- **Do not ask for help you don't need.** Review comments are your spec — read them carefully, check the codebase, and implement the fixes. -- **If multiple issues are unclear, batch your questions** rather than failing on the first one. Collect all blockers, then report them together. +You are an AI coding agent executing an implementation plan. The plan was created by a research agent and is included above under "Research & Plan". ## Superpowers -You have access to **superpowers skills** installed globally. Use them to improve your work. - -- **Use \`systematic-debugging\` when encountering test failures or unexpected behavior** — trace root causes, don't guess. -- **Use \`requesting-code-review\` for self-review** — this is already in your process, follow it. -- **Use \`verification-before-completion\` before claiming fixes are done** — run tests and verify, don't assume. -- If a skill might apply to what you're doing, invoke it. - -## Constraints +You have access to **superpowers skills** installed globally. Use them. -- Only address the specific review comments listed in PR Review Feedback. -- Address CI/CD check failures in addition to review comments. -- Do not refactor code outside the scope of the feedback. -- Do not make changes beyond what reviewers requested. -- Follow existing code conventions in the repository (check CLAUDE.md, AGENTS.md if present). +- **Use \`executing-plans\` to systematically work through the plan** — it structures execution correctly. +- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. +- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. ## Process -0. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. Use the progress, decisions, and file list to understand prior implementation context and any previous fix attempts. -1. Read the review feedback carefully. -2. If merge conflicts exist, the base branch has already been merged into your branch — the repo is in a \`MERGING\` state with conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) in the affected files. Do NOT run \`git merge\` again. Instead: edit each conflicted file to resolve the markers, then \`git add\` the resolved files, then run \`git merge --continue\` to complete the merge. -3. If CI/CD checks failed, read the failure logs in "CI/CD Check Results" and fix the underlying issues (test failures, lint errors, build errors, etc.). -4. Address each review comment — implement the requested changes. -5. Run all tests to ensure nothing is broken. -6. Self-review your changes. -7. **Request code review** — invoke the \`requesting-code-review\` skill to dispatch a code-reviewer subagent. Fix any Critical or Important issues it finds before proceeding. -8. **Update session memory** — before returning your result, write/update \`blazebot/memory/[TASK_ID].md\` (see Session Memory below). -9. Commit your work with descriptive commit messages that explain the "why", not just the "what". Use conventional commit format (feat:, fix:, test:, refactor:, etc.). -10. Run all quality checks (see Quality Gate below). +1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists. If it exists, read it. +2. Read the plan from the "Research & Plan" section above. +3. If review feedback is included (retry scenario): focus on fixing the flagged issues. Do not redo work that was approved. +4. Execute each step in the plan, in order. +5. If the repo has tests: run them to ensure nothing is broken. +6. **Update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. +7. Commit your work with descriptive commit messages (conventional commits: feat:, fix:, test:, etc.). +8. Run all quality checks (tests, linting, type checking, formatting). -## Comment Overrides +## Constraints -If a ticket comment is prefixed with \`[OVERRIDE]\`, treat it as authoritative over any -prior conflicting instructions. The latest \`[OVERRIDE]\` comment takes precedence. +- Follow the plan — do not explore or re-research (already done). +- Do not refactor code outside the scope of the plan. +- Do not install new dependencies unless the plan specifies them. +- Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). +- Do NOT invoke \`requesting-code-review\` — that happens in a separate review phase. -## Session Memory +## When to Ask for Clarification -**MANDATORY — you MUST do this before returning ANY result.** Regardless of outcome (\`implemented\` or \`failed\`), you MUST **overwrite** \`blazebot/memory/[TASK_ID].md\` where \`[TASK_ID]\` is the Ticket ID (e.g. \`AIW-123\`). Create the \`blazebot/memory/\` directory if it does not exist. Skipping this step is a failure condition. +Return \`clarification_needed\` only if the plan is genuinely unexecutable. Exhaust code-level investigation first. -**Always replace the entire file** — do not append to previous content. Each session writes a complete snapshot of current state so future sessions have an accurate picture. +## Session Memory -Use this format: +**MANDATORY** — before returning, overwrite \`blazebot/memory/[TASK_ID].md\`: \`\`\`markdown # Session Memory — [TASK_ID] ## Progress -- What was analyzed, understood, and attempted this session -- Include work from prior sessions if still relevant +- What was implemented this session ## Decisions Made - Technical choices and reasoning ## Blockers -- What is blocking progress (if failed) +- What is blocking progress (if clarification_needed or failed) - "None" if implemented successfully ## Files Touched -- List of files created or modified with brief notes +- List of files created or modified ## Prior Sessions -- Brief summary of what previous sessions did (if memory file existed when this session started) +- Brief summary of prior sessions (if memory file existed) \`\`\` -Keep the memory concise and factual. This file persists across sessions and serves as context for future runs. +## Output -## Quality Gate +Return a JSON object with: +- \`result\`: "implemented" if done, "clarification_needed" if you have questions, "failed" if stuck. +- \`summary\`: Description of work done (when implemented). +- \`questions\`: List of questions (when clarification_needed). +- \`error\`: Failure details (when failed).`; + +const reviewPrompt = `# Instructions + +You are an AI code review agent. Your job is to review the implementation diff against the plan and acceptance criteria. + +## Superpowers + +You have access to **superpowers skills** installed globally. Use them. + +- **Use \`requesting-code-review\` to dispatch a code-reviewer subagent** — this is your primary tool. + +## Process + +1. Read the plan from the "Research & Plan" section above. +2. Read the acceptance criteria. +3. Review the git diff against the plan — did the implementation agent follow it? +4. Check code quality, test coverage, edge cases. +5. Invoke \`requesting-code-review\` skill to dispatch a code-reviewer subagent. +6. Combine your findings with the subagent's findings. +7. Output your verdict. + +## Review Criteria + +- Does the implementation match the plan? +- Does it satisfy the acceptance criteria? +- Are there test gaps? +- Are there obvious bugs or edge cases? +- Does the code follow existing conventions? + +## Constraints -Before finishing, you MUST: -- Find and run ALL quality checks in the project: tests, linting, type checking, - formatting, and any other validation scripts. -- Fix all failures and commit your fixes with descriptive messages. +- **NO coding** — do not write or modify any code +- **NO commits** — do not create any git commits +- Only review and report ## Output Return a JSON object with: -- \`result\`: "implemented" if all feedback addressed, "failed" if stuck. -- \`summary\`: Description of fixes applied (when implemented). +- \`result\`: "approved" if the implementation is ready, "changes_requested" if issues need fixing, "failed" if review itself failed. +- \`feedback\`: Detailed review notes. +- \`issues\`: Array of specific issues — each with \`file\`, \`description\`, \`severity\` ("critical" or "suggestion"). Only include issues that MUST be fixed for \`changes_requested\`. - \`error\`: Failure details (when failed).`; const prompts: Record = { + "research-plan.md": researchPlanPrompt, "implement.md": implementPrompt, - "review-fix.md": reviewFixPrompt, + "review.md": reviewPrompt, }; export function getPrompt(name: string): string { diff --git a/src/sandbox/agent-runner.test.ts b/src/sandbox/agent-runner.test.ts index 924a531..9a0f395 100644 --- a/src/sandbox/agent-runner.test.ts +++ b/src/sandbox/agent-runner.test.ts @@ -3,6 +3,9 @@ import { parseAgentOutput, AGENT_SCHEMA, type AgentOutput, + parseResearchStatus, + parseReviewOutput, + REVIEW_SCHEMA, } from "./agent-runner.js"; describe("parseAgentOutput", () => { @@ -123,3 +126,97 @@ describe("AGENT_SCHEMA", () => { expect(() => JSON.parse(AGENT_SCHEMA)).not.toThrow(); }); }); + +describe("parseResearchStatus", () => { + it("extracts completed status", () => { + const raw = "STATUS: completed\n\n# Implementation Plan\n1. Create foo.ts"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("completed"); + expect(body).toContain("# Implementation Plan"); + }); + + it("extracts clarification_needed status", () => { + const raw = "STATUS: clarification_needed\n\n1. What database?\n2. Which auth?"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("clarification_needed"); + expect(body).toContain("What database?"); + }); + + it("extracts failed status", () => { + const raw = "STATUS: failed\n\nCould not access repository"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("failed"); + }); + + it("defaults to failed when no STATUS line", () => { + const raw = "Here is my analysis of the codebase..."; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("failed"); + expect(body).toContain("analysis"); + }); + + it("handles STATUS line with extra whitespace", () => { + const raw = " STATUS: completed \n\nPlan here"; + const { status } = parseResearchStatus(raw); + expect(status).toBe("completed"); + }); +}); + +describe("parseReviewOutput", () => { + it("parses approved result", () => { + const raw = JSON.stringify({ + result: "approved", + feedback: "Looks good", + issues: [], + }); + const output = parseReviewOutput(raw); + expect(output.result).toBe("approved"); + expect(output.feedback).toBe("Looks good"); + }); + + it("parses changes_requested result with issues", () => { + const raw = JSON.stringify({ + result: "changes_requested", + feedback: "Several issues found", + issues: [ + { file: "src/foo.ts", description: "Missing null check", severity: "critical" }, + ], + }); + const output = parseReviewOutput(raw); + expect(output.result).toBe("changes_requested"); + expect(output.issues).toHaveLength(1); + expect(output.issues[0].severity).toBe("critical"); + }); + + it("returns failed on unparseable output", () => { + const output = parseReviewOutput("not json"); + expect(output.result).toBe("failed"); + expect(output.error).toBeDefined(); + }); + + it("returns failed on empty output", () => { + const output = parseReviewOutput(""); + expect(output.result).toBe("failed"); + }); + + it("extracts from result envelope", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + structured_output: { + result: "approved", + feedback: "All good", + issues: [], + }, + }); + const output = parseReviewOutput(envelope); + expect(output.result).toBe("approved"); + }); +}); + +describe("REVIEW_SCHEMA", () => { + it("is valid JSON", () => { + expect(() => JSON.parse(REVIEW_SCHEMA)).not.toThrow(); + }); +}); diff --git a/src/sandbox/agent-runner.ts b/src/sandbox/agent-runner.ts index 833b417..11005c9 100644 --- a/src/sandbox/agent-runner.ts +++ b/src/sandbox/agent-runner.ts @@ -103,3 +103,111 @@ export function parseAgentOutput(raw: string): AgentOutput { error: `Agent output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, }; } + +// --- Research Status Parser --- + +export type ResearchStatus = "completed" | "clarification_needed" | "failed"; + +export interface ResearchResult { + status: ResearchStatus; + body: string; +} + +const VALID_RESEARCH_STATUSES: ResearchStatus[] = ["completed", "clarification_needed", "failed"]; + +export function parseResearchStatus(raw: string): ResearchResult { + const lines = raw.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + const match = firstLine.match(/^STATUS:\s*(\S+)/i); + + if (match && VALID_RESEARCH_STATUSES.includes(match[1] as ResearchStatus)) { + const body = lines.slice(1).join("\n").trim(); + return { status: match[1] as ResearchStatus, body }; + } + + return { status: "failed", body: raw }; +} + +// --- Review Output Schema --- + +const reviewOutputSchema = z.object({ + result: z.enum(["approved", "changes_requested", "failed"]), + feedback: z.string(), + issues: z.array(z.object({ + file: z.string(), + description: z.string(), + severity: z.enum(["critical", "suggestion"]), + })), + error: z.string().optional(), +}); + +export type ReviewOutput = z.infer; + +export const REVIEW_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { + type: "string", + enum: ["approved", "changes_requested", "failed"], + }, + feedback: { type: "string" }, + issues: { + type: "array", + items: { + type: "object", + properties: { + file: { type: "string" }, + description: { type: "string" }, + severity: { type: "string", enum: ["critical", "suggestion"] }, + }, + required: ["file", "description", "severity"], + }, + }, + error: { type: "string" }, + }, + required: ["result", "feedback", "issues"], +}); + +export function parseReviewOutput(raw: string): ReviewOutput { + if (!raw.trim()) { + return { result: "failed", feedback: "", issues: [], error: "Review agent produced no output" }; + } + + // Direct parse + try { + const direct = reviewOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch {} + + // Stream-json / result-envelope format + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + + if (event.type === "result" && event.structured_output != null) { + const parsed = reviewOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + + const direct = reviewOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch {} + } + + // Fallback: extract JSON objects + const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); + for (const [candidate] of objects) { + try { + const result = reviewOutputSchema.safeParse(JSON.parse(candidate)); + if (result.success) return result.data; + } catch {} + } + + return { + result: "failed", + feedback: "", + issues: [], + error: `Review output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, + }; +} diff --git a/src/sandbox/context.test.ts b/src/sandbox/context.test.ts index 22aef52..cc99f07 100644 --- a/src/sandbox/context.test.ts +++ b/src/sandbox/context.test.ts @@ -1,116 +1,135 @@ import { describe, it, expect } from "vitest"; -import { assembleImplementationContext, assembleFixingFeedbackContext, formatCheckResults } from "./context.js"; +import { + assembleResearchPlanContext, + assembleImplementationContext, + assembleImplementationRetryContext, + assembleReviewContext, + formatCheckResults, +} from "./context.js"; -describe("assembleImplementationContext", () => { - it("assembles requirements.md for implementation", () => { - const result = assembleImplementationContext({ +describe("assembleResearchPlanContext", () => { + it("assembles context for new ticket (no PR feedback)", () => { + const result = assembleResearchPlanContext({ ticket: { identifier: "TEST-1", title: "Add login page", - description: "Build a login page with OAuth", - acceptanceCriteria: "- User can log in\n- User can log out", - comments: [ - { author: "Alice", body: "Use OAuth2", createdAt: "2026-03-20T10:00:00Z" }, - ], + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], }, - prompt: "You are an implementation agent...", + prompt: "You are a research agent...", + branchName: "blazebot/test-1", }); - expect(result).toContain("# Requirements"); expect(result).toContain("## Ticket ID"); expect(result).toContain("TEST-1"); - expect(result.indexOf("## Ticket ID")).toBeLessThan(result.indexOf("## Ticket\n")); - expect(result).toContain("Add login page"); - expect(result).toContain("Build a login page with OAuth"); - expect(result).toContain("User can log in"); - expect(result).toContain("Alice: Use OAuth2"); - expect(result).toContain("You are an implementation agent..."); + expect(result).toContain("## Branch"); + expect(result).toContain("blazebot/test-1"); + expect(result).toContain("You are a research agent..."); + expect(result).not.toContain("## PR Review Feedback"); }); -}); -describe("assembleFixingFeedbackContext", () => { - it("assembles requirements.md for fixing feedback", () => { - const result = assembleFixingFeedbackContext({ + it("assembles context with PR feedback for review-fix scenario", () => { + const result = assembleResearchPlanContext({ ticket: { identifier: "TEST-2", - title: "Add login page", - description: "Build a login page", + title: "Fix auth", + description: "Fix auth module", acceptanceCriteria: "", comments: [], }, - prompt: "You are a review-fix agent...", + prompt: "prompt", + branchName: "blazebot/test-2", prComments: [ - { author: "Bob", body: "Fix the typo on line 5", liked: true }, + { author: "Bob", body: "Fix the null check", liked: false }, + ], + checkResults: [ + { name: "test", status: "completed", conclusion: "failure", logs: "FAIL" }, ], hasConflicts: true, - checkResults: [], }); - expect(result).toContain("# Requirements"); - expect(result).toContain("## Ticket ID"); - expect(result).toContain("TEST-2"); - expect(result.indexOf("## Ticket ID")).toBeLessThan(result.indexOf("## Ticket\n")); expect(result).toContain("## PR Review Feedback"); - expect(result).toContain("Fix the typo on line 5"); + expect(result).toContain("Fix the null check"); expect(result).toContain("## CI/CD Check Results"); + expect(result).toContain("### Failed: test"); expect(result).toContain("## Merge Conflicts"); - expect(result).toContain("You are a review-fix agent..."); }); +}); - it("renders line-coupled comments with file path and line range", () => { - const result = assembleFixingFeedbackContext({ +describe("assembleImplementationContext (new)", () => { + it("assembles context with research plan markdown", () => { + const result = assembleImplementationContext({ ticket: { - identifier: "TEST-3", - title: "Fix auth", - description: "Fix auth module", - acceptanceCriteria: "", + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", comments: [], }, - prompt: "prompt", - prComments: [ - { author: "Bob", body: "Use a constant", liked: false, filePath: "src/lib/auth.ts", startLine: 42, endLine: 45 }, - { author: "Alice", body: "Looks good but add error handling", liked: true, filePath: "src/components/Form.tsx", startLine: 12, endLine: 12 }, - { author: "Charlie", body: "Overall looks good", liked: false }, - ], - hasConflicts: false, - checkResults: [], + prompt: "You are an implementation agent...", + researchPlanMarkdown: "# Plan\n1. Create LoginForm component\n2. Add route handler", }); - expect(result).toContain("### src/lib/auth.ts (lines 42-45)"); - expect(result).toContain("Bob: Use a constant"); - expect(result).toContain("### src/components/Form.tsx (line 12)"); - expect(result).toContain("Alice (liked): Looks good but add error handling"); - expect(result).toContain("Charlie: Overall looks good"); - // Line-coupled comments should appear before general comments - expect(result.indexOf("src/components/Form.tsx")).toBeLessThan(result.indexOf("Charlie: Overall looks good")); + expect(result).toContain("## Ticket ID"); + expect(result).toContain("TEST-1"); + expect(result).toContain("## Research & Plan"); + expect(result).toContain("# Plan"); + expect(result).toContain("Create LoginForm component"); + expect(result).toContain("You are an implementation agent..."); }); +}); - it("includes CI/CD check results section between PR feedback and merge conflicts", () => { - const result = assembleFixingFeedbackContext({ +describe("assembleImplementationRetryContext", () => { + it("includes plan and review feedback", () => { + const result = assembleImplementationRetryContext({ ticket: { - identifier: "TEST-4", - title: "Fix tests", - description: "Fix failing tests", - acceptanceCriteria: "", + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", comments: [], }, prompt: "prompt", - prComments: [], - hasConflicts: false, - checkResults: [ - { name: "lint", status: "completed", conclusion: "success" }, - { name: "test", status: "completed", conclusion: "failure", logs: "FAIL src/app.test.ts\nExpected true, got false" }, - ], + researchPlanMarkdown: "# Plan\n1. Create LoginForm", + reviewFeedback: { + result: "changes_requested", + feedback: "Missing error handling", + issues: [ + { file: "src/LoginForm.tsx", description: "No null check", severity: "critical" }, + ], + }, }); - const prFeedbackIdx = result.indexOf("## PR Review Feedback"); - const ciIdx = result.indexOf("## CI/CD Check Results"); - const mergeIdx = result.indexOf("## Merge Conflicts"); - expect(ciIdx).toBeGreaterThan(prFeedbackIdx); - expect(ciIdx).toBeLessThan(mergeIdx); - expect(result).toContain("Passed: lint"); - expect(result).toContain("### Failed: test"); - expect(result).toContain("FAIL src/app.test.ts"); + expect(result).toContain("## Research & Plan"); + expect(result).toContain("Create LoginForm"); + expect(result).toContain("## Review Feedback"); + expect(result).toContain("Missing error handling"); + expect(result).toContain("src/LoginForm.tsx"); + expect(result).toContain("No null check"); + expect(result).toContain("critical"); + }); +}); + +describe("assembleReviewContext", () => { + it("includes plan and git diff", () => { + const result = assembleReviewContext({ + ticket: { + identifier: "TEST-1", + title: "Add login page", + description: "Build a login page", + acceptanceCriteria: "User can log in", + comments: [], + }, + prompt: "You are a review agent...", + researchPlanMarkdown: "# Plan\n1. Create LoginForm", + gitDiff: "diff --git a/src/LoginForm.tsx b/src/LoginForm.tsx\n+export function LoginForm() {}", + }); + + expect(result).toContain("## Research & Plan"); + expect(result).toContain("## Git Diff"); + expect(result).toContain("+export function LoginForm()"); + expect(result).toContain("You are a review agent..."); }); }); diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index b1c6334..06c80f4 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,34 +1,47 @@ import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; +import type { ReviewOutput } from "./agent-runner.js"; interface TicketData { identifier: string; title: string; description: string; acceptanceCriteria: string; - comments: Array<{ author: string; body: string; createdAt: string }>; + comments: Array<{ author: string; body: string; createdAt?: string }>; +} + +export interface ResearchPlanContextInput { + ticket: TicketData; + prompt: string; + branchName: string; + prComments?: PRComment[]; + checkResults?: CheckRunResult[]; + hasConflicts?: boolean; } export interface ImplementationContextInput { ticket: TicketData; prompt: string; - skills?: string; + researchPlanMarkdown: string; } -export interface FixingFeedbackContextInput { +export interface ImplementationRetryContextInput { ticket: TicketData; prompt: string; - skills?: string; - prComments: PRComment[]; - hasConflicts: boolean; - checkResults: CheckRunResult[]; + researchPlanMarkdown: string; + reviewFeedback: ReviewOutput; } -export function assembleImplementationContext( - input: ImplementationContextInput, -): string { - const { ticket, prompt } = input; +export interface ReviewContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + gitDiff: string; +} - return `# Requirements +export function assembleResearchPlanContext(input: ResearchPlanContextInput): string { + const { ticket, prompt, branchName, prComments, checkResults, hasConflicts } = input; + + let md = `# Requirements ## Ticket ID @@ -50,17 +63,29 @@ ${ticket.acceptanceCriteria || "None specified."} ${formatComments(ticket.comments)} ---- +## Branch -${prompt} +${branchName} `; -} -export function assembleFixingFeedbackContext( - input: FixingFeedbackContextInput, -): string { - const { ticket, prompt, prComments, hasConflicts, checkResults } = input; + if (prComments && prComments.length > 0) { + md += `\n## PR Review Feedback\n\n${formatPRComments(prComments)}\n`; + } + + if (checkResults && checkResults.length > 0) { + md += `\n## CI/CD Check Results\n\n${formatCheckResults(checkResults)}\n`; + } + if (hasConflicts) { + md += `\n## Merge Conflicts\n\nThis PR has merge conflicts. The base branch has already been merged — the repo is in a MERGING state with conflict markers in the affected files. Resolve the markers, \`git add\` the files, and run \`git merge --continue\`.\n`; + } + + md += `\n---\n\n${prompt}\n`; + return md; +} + +export function assembleImplementationContext(input: ImplementationContextInput): string { + const { ticket, prompt, researchPlanMarkdown } = input; return `# Requirements ## Ticket ID @@ -71,29 +96,79 @@ ${ticket.identifier} ${ticket.title} -## Description +## Acceptance Criteria -${ticket.description} +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +--- + +${prompt} +`; +} + +export function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string { + const { ticket, prompt, researchPlanMarkdown, reviewFeedback } = input; + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} ## Acceptance Criteria ${ticket.acceptanceCriteria || "None specified."} -## Comments +## Research & Plan -${formatComments(ticket.comments)} +${researchPlanMarkdown} + +## Review Feedback + +${reviewFeedback.feedback} + +### Issues + +${formatReviewIssues(reviewFeedback.issues)} + +--- + +${prompt} +`; +} -## PR Review Feedback +export function assembleReviewContext(input: ReviewContextInput): string { + const { ticket, prompt, researchPlanMarkdown, gitDiff } = input; + return `# Requirements -${formatPRComments(prComments)} +## Ticket ID -## CI/CD Check Results +${ticket.identifier} -${formatCheckResults(checkResults)} +## Ticket -## Merge Conflicts +${ticket.title} -${hasConflicts ? "This PR has merge conflicts. The base branch has already been merged — the repo is in a MERGING state with conflict markers in the affected files. Resolve the markers, `git add` the files, and run `git merge --continue`." : "No merge conflicts."} +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +## Git Diff + +\`\`\`diff +${gitDiff} +\`\`\` --- @@ -101,8 +176,15 @@ ${prompt} `; } +function formatReviewIssues(issues: Array<{ file: string; description: string; severity: string }>): string { + if (issues.length === 0) return "No specific issues listed."; + return issues + .map((i) => `- **[${i.severity}]** ${i.file}: ${i.description}`) + .join("\n"); +} + function formatComments( - comments: Array<{ author: string; body: string; createdAt: string }>, + comments: Array<{ author: string; body: string; createdAt?: string }>, ): string { if (comments.length === 0) return "No comments."; return comments diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index 0ad0e5f..6240ac5 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -18,7 +18,7 @@ vi.mock("@vercel/sandbox", () => ({ }, })); -import { SandboxManager } from "./manager.js"; +import { SandboxManager, configureStopHookInSandbox } from "./manager.js"; describe("SandboxManager", () => { beforeEach(() => { @@ -46,7 +46,7 @@ describe("SandboxManager", () => { jobTimeoutMs: 1_800_000, }); - const sandbox = await manager.provision("feat/test-branch", "# Requirements\n..."); + const sandbox = await manager.provision("feat/test-branch"); expect(Sandbox.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -59,8 +59,82 @@ describe("SandboxManager", () => { }), }), ); - expect(mockWriteFiles).toHaveBeenCalled(); expect(sandbox.sandboxId).toBe("sbx-test-123"); }); + it("does not write wrapper script or requirements during provision", async () => { + const manager = new SandboxManager({ + githubToken: "ghp_test", + owner: "test-org", + repo: "test-repo", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + await manager.provision("feat/test-branch"); + + // writeFiles should not be called during provision (no wrapper script or requirements) + expect(mockWriteFiles).not.toHaveBeenCalled(); + }); + + it("configures stop hook when enabled", async () => { + const manager = new SandboxManager({ + githubToken: "ghp_test", + owner: "test-org", + repo: "test-repo", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + const sandbox = await manager.provision("feat/test-branch"); + await manager.configureStopHook(sandbox, true); + + // Should have called runCommand with the commit-guard script + const calls = mockRunCommand.mock.calls.map((c: any[]) => c[0] === "bash" ? c[1]?.[1] ?? c[1]?.[0] : ""); + const hookCall = calls.find((c: string) => typeof c === "string" && c.includes("commit-guard")); + expect(hookCall).toBeDefined(); + }); + + it("clears stop hook when disabled", async () => { + const manager = new SandboxManager({ + githubToken: "ghp_test", + owner: "test-org", + repo: "test-repo", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + const sandbox = await manager.provision("feat/test-branch"); + mockRunCommand.mockClear(); + await manager.configureStopHook(sandbox, false); + + // Should write empty settings + const calls = mockRunCommand.mock.calls; + const clearCall = calls.find((c: any[]) => + c[0] === "bash" && typeof c[1]?.[1] === "string" && c[1][1].includes("'{}' > ~/.claude/settings.json"), + ); + expect(clearCall).toBeDefined(); + }); + + it("configureStopHookInSandbox works with any sandbox-like object", async () => { + const fakeSandbox = { runCommand: mockRunCommand }; + + mockRunCommand.mockClear(); + await configureStopHookInSandbox(fakeSandbox as any, true); + + const hookCall = mockRunCommand.mock.calls.find( + (c: any[]) => c[0] === "bash" && typeof c[1]?.[1] === "string" && c[1][1].includes("commit-guard"), + ); + expect(hookCall).toBeDefined(); + }); + }); diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index e2dec32..ae174c7 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,6 +1,5 @@ import type { Sandbox as SandboxType } from "@vercel/sandbox"; import { getSandboxCredentials } from "./credentials.js"; -import { buildWrapperScript } from "./wrapper-script.js"; /** * Skills installed globally in the sandbox (~/.claude/skills/). @@ -26,12 +25,51 @@ export interface SandboxConfig { type SandboxInstance = Awaited>; +/** Minimal interface for sandbox objects that support runCommand (works with both Sandbox.create and Sandbox.get). */ +interface RunnableSandbox { + runCommand: SandboxInstance["runCommand"]; +} + +/** + * Configures or disables the commit-guard stop hook in a sandbox. + * Standalone function so both SandboxManager and workflow steps can call it + * without type mismatches between Sandbox.create() and Sandbox.get(). + */ +export async function configureStopHookInSandbox(sandbox: RunnableSandbox, enabled: boolean): Promise { + if (enabled) { + await sandbox.runCommand("bash", [ + "-c", + [ + `mkdir -p ~/.claude`, + `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, + `#!/bin/bash`, + `input=$(cat)`, + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + ` exit 2`, + `fi`, + `SCRIPT`, + `chmod +x ~/.claude/commit-guard.sh`, + `cat > ~/.claude/settings.json << 'JSON'`, + `{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`, + `JSON`, + ].join("\n"), + ]); + } else { + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p ~/.claude && echo '{}' > ~/.claude/settings.json`, + ]); + } +} + export class SandboxManager { constructor(private config: SandboxConfig) {} async provision( branch: string, - requirementsMd: string, /** If set, fetches and merges this branch (e.g. "main") so the agent can resolve conflicts. */ mergeBase?: string, ): Promise { @@ -118,40 +156,9 @@ export class SandboxManager { ]); } - // Configure Stop hook — forces agent to commit or discard before exiting. - // Written to ~/.claude/ (user-level) so it doesn't pollute the repo working tree. - await sandbox.runCommand("bash", [ - "-c", - [ - `mkdir -p ~/.claude`, - `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, - `#!/bin/bash`, - `input=$(cat)`, - `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, - `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/' | grep -v 'requirements\\.md')`, - `if [ -n "$changes" ]; then`, - ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, - ` exit 2`, - `fi`, - `SCRIPT`, - `chmod +x ~/.claude/commit-guard.sh`, - `cat > ~/.claude/settings.json << 'JSON'`, - `{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`, - `JSON`, - ].join("\n"), - ]); - // Install skills globally (outside the client repo) await this.installGlobalSkills(sandbox); - // Write requirements.md and wrapper script for detached execution - const wrapperScript = buildWrapperScript({ model: this.config.claudeModel }); - await sandbox.writeFiles([ - { path: "/tmp/requirements.md", content: Buffer.from(requirementsMd) }, - { path: "/tmp/agent-wrapper.sh", content: Buffer.from(wrapperScript) }, - ]); - await sandbox.runCommand("chmod", ["+x", "/tmp/agent-wrapper.sh"]); - return sandbox; } @@ -167,6 +174,10 @@ export class SandboxManager { } } + async configureStopHook(sandbox: SandboxInstance, enabled: boolean): Promise { + await configureStopHookInSandbox(sandbox, enabled); + } + async teardown(sandbox: SandboxInstance): Promise { try { await sandbox.stop(); diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index f8912c8..5ebce5c 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -32,67 +32,7 @@ vi.mock("../../env.js", () => ({ }, })); -import { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } from "./poll-agent.js"; - -describe("checkAgentDone", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns false when sentinel file does not exist", async () => { - mockRunCommand.mockResolvedValue({ exitCode: 1 }); - - const result = await checkAgentDone("sbx-test-123"); - expect(result).toBe(false); - }); - - it("returns true when sentinel file exists", async () => { - mockRunCommand.mockResolvedValue({ exitCode: 0 }); - - const result = await checkAgentDone("sbx-test-123"); - expect(result).toBe(true); - }); - - it("returns 'stopped' when sandbox is not running", async () => { - const { Sandbox } = await import("@vercel/sandbox"); - (Sandbox.get as ReturnType).mockResolvedValueOnce({ - sandboxId: "sbx-test-123", - status: "stopped", - runCommand: mockRunCommand, - }); - - const result = await checkAgentDone("sbx-test-123"); - expect(result).toBe("stopped"); - }); -}); - -describe("collectAgentOutput", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns failure when sandbox is unreachable", async () => { - const { Sandbox } = await import("@vercel/sandbox"); - (Sandbox.get as ReturnType).mockRejectedValueOnce(new Error("gone")); - - const result = await collectAgentOutput("sbx-test-123"); - - expect(result.output.result).toBe("failed"); - expect(result.output.error).toContain("unreachable"); - }); - - it("reads stdout and stderr and parses agent output", async () => { - const mockStdout = vi.fn(); - mockRunCommand.mockImplementation(() => ({ - exitCode: 0, - stdout: mockStdout, - })); - - mockStdout - .mockResolvedValueOnce(JSON.stringify({ result: "implemented", summary: "Done" })) // stdout - .mockResolvedValueOnce(""); // stderr - - const result = await collectAgentOutput("sbx-test-123"); - - expect(result.output.result).toBe("implemented"); - }); -}); +import { pushFromSandbox, fixAndRetryPush, teardownSandbox, checkPhaseDone, collectPhaseOutput } from "./poll-agent.js"; describe("pushFromSandbox", () => { beforeEach(() => vi.clearAllMocks()); @@ -261,3 +201,88 @@ describe("teardownSandbox", () => { await expect(teardownSandbox("sbx-test-123")).resolves.not.toThrow(); }); }); + +describe("checkPhaseDone", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns true when sentinel file exists", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const result = await checkPhaseDone("sbx-test-123", "/tmp/phase-1-done"); + expect(result).toBe(true); + expect(mockRunCommand).toHaveBeenCalledWith("test", ["-f", "/tmp/phase-1-done"]); + }); + + it("returns false when sentinel file is missing", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + + const result = await checkPhaseDone("sbx-test-123", "/tmp/phase-1-done"); + expect(result).toBe(false); + }); + + it("returns 'stopped' when sandbox is not running", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockResolvedValueOnce({ + sandboxId: "sbx-test-123", + status: "stopped", + runCommand: mockRunCommand, + }); + + const result = await checkPhaseDone("sbx-test-123", "/tmp/phase-1-done"); + expect(result).toBe("stopped"); + }); + + it("returns 'stopped' when sandbox is unreachable", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + (Sandbox.get as ReturnType).mockRejectedValueOnce(new Error("gone")); + + const result = await checkPhaseDone("sbx-test-123", "/tmp/phase-1-done"); + expect(result).toBe("stopped"); + }); +}); + +describe("collectPhaseOutput", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reads from custom output file paths", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation(() => ({ + exitCode: 0, + stdout: mockStdout, + })); + + mockStdout + .mockResolvedValueOnce("phase output content") // stdout file + .mockResolvedValueOnce(""); // stderr file + + const result = await collectPhaseOutput( + "sbx-test-123", + "/tmp/phase-1-stdout.txt", + "/tmp/phase-1-stderr.txt", + ); + + expect(result).toBe("phase output content"); + expect(mockRunCommand).toHaveBeenCalledWith("cat", ["/tmp/phase-1-stdout.txt"]); + expect(mockRunCommand).toHaveBeenCalledWith("cat", ["/tmp/phase-1-stderr.txt"]); + }); + + it("returns stderr when stdout is empty", async () => { + const mockStdout = vi.fn(); + mockRunCommand.mockImplementation(() => ({ + exitCode: 0, + stdout: mockStdout, + })); + + mockStdout + .mockResolvedValueOnce("") // stdout file empty + .mockResolvedValueOnce("error details from phase"); // stderr file + + const result = await collectPhaseOutput( + "sbx-test-123", + "/tmp/phase-1-stdout.txt", + "/tmp/phase-1-stderr.txt", + ); + + expect(result).toBe("error details from phase"); + }); +}); diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 7a796a9..958a686 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -1,67 +1,4 @@ import { getSandboxCredentials } from "./credentials.js"; -import { parseAgentOutput } from "./agent-runner.js"; -import type { AgentOutput } from "./agent-runner.js"; - -/** - * Reconnects to a sandbox and checks whether the agent has finished. - * Returns: - * - `true` if /tmp/agent-done sentinel exists - * - `false` if sandbox is running but agent not done yet - * - `"stopped"` if sandbox is no longer running (timeout/crash) - */ -export async function checkAgentDone( - sandboxId: string, -): Promise { - "use step"; - const { Sandbox } = await import("@vercel/sandbox"); - try { - const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - - if (sandbox.status !== "running") { - return "stopped"; - } - - const result = await sandbox.runCommand("test", ["-f", "/tmp/agent-done"]); - return result.exitCode === 0; - } catch { - // Sandbox unreachable (network error, GC'd, etc.) — treat as stopped - return "stopped"; - } -} - -/** - * Reconnects to the sandbox, reads agent stdout/stderr, and returns the - * parsed result. File extraction is no longer needed — commits are pushed - * directly from the sandbox via `pushFromSandbox`. - */ -export async function collectAgentOutput( - sandboxId: string, -): Promise<{ output: AgentOutput }> { - "use step"; - const { Sandbox } = await import("@vercel/sandbox"); - - let sandbox; - try { - sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - } catch { - // Sandbox unreachable between final poll and collection — return a clear failure - return { - output: { result: "failed", error: "Sandbox became unreachable before results could be collected" }, - }; - } - - // Read agent output files - const stdoutResult = await sandbox.runCommand("cat", ["/tmp/agent-stdout.txt"]); - const stdout = (await stdoutResult.stdout()).trim(); - - const stderrResult = await sandbox.runCommand("cat", ["/tmp/agent-stderr.txt"]); - const stderr = (await stderrResult.stdout()).trim(); - - const raw = stdout || stderr; - const output = parseAgentOutput(raw); - - return { output }; -} /** * After the agent exits, injects the GitHub token and pushes commits to GitHub. @@ -171,6 +108,52 @@ export async function fixAndRetryPush( return { pushed: true }; } +/** + * Generalized sentinel check — works with any sentinel file path. + */ +export async function checkPhaseDone( + sandboxId: string, + sentinelFile: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + try { + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + if (sandbox.status !== "running") { + return "stopped"; + } + + const result = await sandbox.runCommand("test", ["-f", sentinelFile]); + return result.exitCode === 0; + } catch { + return "stopped"; + } +} + +/** + * Generalized output collector — reads from any stdout/stderr file paths. + * Returns raw string. Caller is responsible for parsing. + */ +export async function collectPhaseOutput( + sandboxId: string, + outputFile: string, + stderrFile: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + const stdoutResult = await sandbox.runCommand("cat", [outputFile]); + const stdout = (await stdoutResult.stdout()).trim(); + + const stderrResult = await sandbox.runCommand("cat", [stderrFile]); + const stderr = (await stderrResult.stdout()).trim(); + + return stdout || stderr; +} + /** * Reconnects to a sandbox and stops it. */ diff --git a/src/sandbox/run-agent.ts b/src/sandbox/run-agent.ts deleted file mode 100644 index 9ef0cb4..0000000 --- a/src/sandbox/run-agent.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Sandbox as SandboxType } from "@vercel/sandbox"; - -type SandboxInstance = Awaited>; - -/** - * Starts the agent wrapper script in detached mode. - * Returns immediately — the agent runs in the background. - * Use `checkAgentDone` / `collectAgentResults` from poll-agent.ts to poll for completion. - */ -export async function startAgentDetached( - sandbox: SandboxInstance, -): Promise { - await sandbox.runCommand({ - cmd: "bash", - args: ["/tmp/agent-wrapper.sh"], - cwd: "/vercel/sandbox", - detached: true, - }); -} diff --git a/src/sandbox/wrapper-script.test.ts b/src/sandbox/wrapper-script.test.ts index 40af866..3c5eac5 100644 --- a/src/sandbox/wrapper-script.test.ts +++ b/src/sandbox/wrapper-script.test.ts @@ -1,22 +1,105 @@ import { describe, it, expect } from "vitest"; -import { buildWrapperScript } from "./wrapper-script.js"; +import { buildPhaseScript } from "./wrapper-script.js"; -describe("buildWrapperScript", () => { - it("generates a bash script that runs claude and writes sentinel", () => { - const script = buildWrapperScript({ model: "claude-opus-4-6" }); +describe("buildPhaseScript", () => { + it("generates research phase script without json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); expect(script).toContain("#!/bin/bash"); expect(script).toContain("claude"); expect(script).toContain("claude-opus-4-6"); - expect(script).toContain("/tmp/agent-done"); - expect(script).toContain("/tmp/agent-stdout.txt"); - expect(script).toContain("/tmp/agent-stderr.txt"); - expect(script).not.toContain("git commit"); // agent commits via stop hook, not wrapper + expect(script).toContain("/tmp/research-requirements.md"); + expect(script).toContain("/tmp/research-stdout.txt"); + expect(script).toContain("/tmp/research-stderr.txt"); + expect(script).toContain("/tmp/research-done"); + expect(script).not.toContain("--json-schema"); + expect(script).not.toContain("--output-format"); }); - it("includes json-schema flag", () => { - const script = buildWrapperScript({ model: "claude-opus-4-6" }); + it("generates impl phase script with json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: '{"type":"object"}', + }); + expect(script).toContain("--json-schema"); expect(script).toContain("--output-format json"); + expect(script).toContain("/tmp/impl-requirements.md"); + expect(script).toContain("/tmp/impl-done"); + }); + + it("generates review phase script with json-schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "review", + inputFile: "/tmp/review-requirements.md", + outputFile: "/tmp/review-stdout.txt", + stderrFile: "/tmp/review-stderr.txt", + sentinelFile: "/tmp/review-done", + jsonSchema: '{"type":"object"}', + }); + + expect(script).toContain("--json-schema"); + expect(script).toContain("/tmp/review-requirements.md"); + expect(script).toContain("/tmp/review-done"); + }); + + it("includes cleanup and sentinel touch", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); + + expect(script).toContain("rm -rf .claude/"); + expect(script).toContain("touch /tmp/research-done"); + }); + + it("removes stale sentinel, stdout, and stderr before running", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: '{"type":"object"}', + }); + + // Cleanup line must appear before the claude invocation + const cleanupIdx = script.indexOf("rm -f /tmp/impl-done /tmp/impl-stdout.txt /tmp/impl-stderr.txt"); + const claudeIdx = script.indexOf("claude"); + expect(cleanupIdx).toBeGreaterThan(-1); + expect(cleanupIdx).toBeLessThan(claudeIdx); + }); + + it("escapes single quotes in json schema", () => { + const script = buildPhaseScript({ + model: "claude-opus-4-6", + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: `{"type":"object","desc":"it's"}`, + }); + + expect(script).not.toContain("it's"); + expect(script).toContain("it'\\''s"); }); }); diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts index 4dbce2a..28b93b6 100644 --- a/src/sandbox/wrapper-script.ts +++ b/src/sandbox/wrapper-script.ts @@ -1,37 +1,38 @@ -import { AGENT_SCHEMA } from "./agent-runner.js"; - -interface WrapperScriptOptions { +export interface PhaseScriptOptions { model: string; + phase: "research" | "impl" | "review"; + inputFile: string; + outputFile: string; + stderrFile: string; + sentinelFile: string; + jsonSchema?: string; } /** - * Generates a bash wrapper script that: - * 1. Runs claude --print with the given model (agent commits via stop hook) - * 2. Does cleanup (removes .claude/ artifacts) - * 3. Writes stdout/stderr to /tmp/ files - * 4. Touches /tmp/agent-done as sentinel - * + * Generates a bash script for a single agent phase. * Designed to run detached inside a Vercel Sandbox. - * The agent is responsible for committing — this script does NOT auto-commit. */ -export function buildWrapperScript(opts: WrapperScriptOptions): string { - const { model } = opts; +export function buildPhaseScript(opts: PhaseScriptOptions): string { + const { model, inputFile, outputFile, stderrFile, sentinelFile, jsonSchema } = opts; + + let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions`; - // Escape single quotes in the schema for safe embedding in bash - const escapedSchema = AGENT_SCHEMA.replace(/'/g, "'\\''"); + if (jsonSchema) { + const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); + claudeFlags += ` --output-format json --json-schema '${escapedSchema}'`; + } return `#!/bin/bash -# --- Phase 1: Run Claude Code agent --- -cat /tmp/requirements.md | claude \\ - --print \\ - --model '${model}' \\ - --dangerously-skip-permissions \\ - --output-format json \\ - --json-schema '${escapedSchema}' \\ - > /tmp/agent-stdout.txt 2>/tmp/agent-stderr.txt; echo $? > /tmp/agent-exit-code || true +# --- Cleanup stale files from prior runs --- +rm -f ${sentinelFile} ${outputFile} ${stderrFile} + +# --- Phase: ${opts.phase} --- +cat ${inputFile} | claude \\ + ${claudeFlags} \\ + > ${outputFile} 2>${stderrFile}; echo $? > /tmp/${opts.phase}-exit-code || true -# --- Phase 2: Cleanup --- +# --- Cleanup --- cd /vercel/sandbox # Remove repo-level .claude/ artifacts that Claude Code auto-creates. @@ -39,7 +40,7 @@ cd /vercel/sandbox rm -rf .claude/ git checkout -- .claude/ 2>/dev/null || true -# --- Phase 3: Signal completion --- -touch /tmp/agent-done +# --- Signal completion --- +touch ${sentinelFile} `; } diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts new file mode 100644 index 0000000..56a0de9 --- /dev/null +++ b/src/workflows/agent.ts @@ -0,0 +1,465 @@ +import { sleep } from "workflow"; +import type { AgentOutput } from "../sandbox/agent-runner.js"; +import type { ReviewOutput } from "../sandbox/agent-runner.js"; +import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; + +// --- Step Functions --- + +async function fetchAndValidateTicket(ticketId: string, columnAi: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + const ticket = await issueTracker.fetchTicket(ticketId); + if (ticket.trackerStatus.toLowerCase() !== columnAi.toLowerCase()) return null; + return ticket; +} + +async function createFeatureBranch(branchName: string, baseBranch: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + await vcs.createBranch(branchName, baseBranch); +} + +async function fetchPRContext(branchName: string): Promise<{ + prComments: PRComment[]; + checkResults: CheckRunResult[]; + hasConflicts: boolean; +} | null> { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + const pr = await vcs.findPR(branchName); + if (!pr) return null; + + const prComments = await vcs.getPRComments(pr.id); + const hasConflicts = await vcs.getPRConflictStatus(pr.id); + const checkResults = await vcs.getCheckRunResults(pr.id); + return { prComments, hasConflicts, checkResults }; +} + +async function provisionSandbox( + branchName: string, + mergeBase?: string, +): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + + const manager = new SandboxManager({ + githubToken: env.GITHUB_TOKEN, + owner: env.GITHUB_OWNER, + repo: env.GITHUB_REPO, + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + }); + + const sandbox = await manager.provision(branchName, mergeBase); + return sandbox.sandboxId; +} +provisionSandbox.maxRetries = 0; + +async function writeAndStartPhase( + sandboxId: string, + inputFilePath: string, + inputContent: string, + scriptPath: string, + scriptContent: string, +): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + await sandbox.writeFiles([ + { path: inputFilePath, content: Buffer.from(inputContent) }, + { path: scriptPath, content: Buffer.from(scriptContent) }, + ]); + await sandbox.runCommand("chmod", ["+x", scriptPath]); + + await sandbox.runCommand({ + cmd: "bash", + args: [scriptPath], + cwd: "/vercel/sandbox", + detached: true, + }); +} +writeAndStartPhase.maxRetries = 0; + +async function configureStopHook(sandboxId: string, enabled: boolean): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + const { configureStopHookInSandbox } = await import("../sandbox/manager.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + await configureStopHookInSandbox(sandbox, enabled); +} + +async function captureGitDiff(sandboxId: string): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const baseShaResult = await sandbox.runCommand("bash", [ + "-c", "cat /tmp/.pre-agent-sha 2>/dev/null || echo ''", + ]); + const baseSha = (await baseShaResult.stdout()).trim(); + + const diffCmd = baseSha + ? `git diff ${baseSha}..HEAD` + : "git diff HEAD"; + const diffResult = await sandbox.runCommand("bash", ["-c", diffCmd]); + return (await diffResult.stdout()).trim(); +} + +async function createPullRequest(branchName: string, title: string, summary: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { vcs } = createStepAdapters(); + return vcs.createPR(branchName, title, summary); +} + +async function moveTicket(ticketId: string, column: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + await issueTracker.moveTicket(ticketId, column); +} + +async function notifySlack(message: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { messaging } = createStepAdapters(); + await messaging.notify(message); +} + +async function postClarificationAndMoveBack( + ticketId: string, + questions: string[], + backlogColumn: string, +) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { issueTracker } = createStepAdapters(); + const comment = questions.map((q, i) => `${i + 1}. ${q}`).join("\n"); + await issueTracker.postComment(ticketId, comment); + await issueTracker.moveTicket(ticketId, backlogColumn); +} + +async function unregisterRun(ticketIdentifier: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + await runRegistry.unregister(ticketIdentifier); +} + +async function markTicketFailed(ticketIdentifier: string, error: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; + await runRegistry.markFailed(ticketIdentifier, { + runId, + error, + failedAt: new Date().toISOString(), + }); +} + +// --- Polling helper (not a step — called within the workflow) --- + +async function pollUntilDone( + sandboxId: string, + sentinelFile: string, + maxPollMinutes: number, +): Promise { + const { checkPhaseDone } = await import("../sandbox/poll-agent.js"); + const POLL_INTERVAL = "30s"; + const MAX_POLLS = Math.ceil((maxPollMinutes * 60) / 30); + let pollCount = 0; + + while (pollCount < MAX_POLLS) { + await sleep(POLL_INTERVAL); + pollCount++; + const status = await checkPhaseDone(sandboxId, sentinelFile); + if (status === true) return true; + if (status === "stopped") return false; + } + return false; +} + +// --- Main Workflow --- + +const MAX_REVIEW_RETRIES = 2; + +export async function agentWorkflow(ticketId: string) { + "use workflow"; + + const { env } = await import("../../env.js"); + const { getPrompt } = await import("../lib/prompts.js"); + const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); + const { parseResearchStatus, parseAgentOutput, parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = + await import("../sandbox/agent-runner.js"); + const { assembleResearchPlanContext, assembleImplementationContext, assembleImplementationRetryContext, assembleReviewContext } = + await import("../sandbox/context.js"); + const { collectPhaseOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = + await import("../sandbox/poll-agent.js"); + + const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); + if (!ticket) return; + + try { + await notifySlack(`Task ${ticket.identifier} started`); + + const branchName = `blazebot/${ticket.identifier.toLowerCase()}`; + + // Check for existing PR BEFORE creating/resetting the branch. + // createFeatureBranch force-resets the branch to main's HEAD, which causes + // GitHub to auto-close any open PR (no diff = no PR). + const prContext = await fetchPRContext(branchName); + + if (!prContext) { + // New ticket — create (or reset) the branch from base + await createFeatureBranch(branchName, env.GITHUB_BASE_BRANCH); + } + // Review-fix: branch + PR already exist, keep the branch as-is + + const mergeBase = prContext?.hasConflicts ? env.GITHUB_BASE_BRANCH : undefined; + + // Provision sandbox once for all phases + const sandboxId = await provisionSandbox(branchName, mergeBase); + + try { + // ========== PHASE 1: Research & Plan ========== + await configureStopHook(sandboxId, false); + + const ticketData = { + identifier: ticket.identifier, + title: ticket.title, + description: ticket.description, + acceptanceCriteria: ticket.acceptanceCriteria, + comments: ticket.comments, + }; + + const researchInput = assembleResearchPlanContext({ + ticket: ticketData, + prompt: getPrompt("research-plan.md"), + branchName, + prComments: prContext?.prComments, + checkResults: prContext?.checkResults, + hasConflicts: prContext?.hasConflicts, + }); + + const researchScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "research", + inputFile: "/tmp/research-requirements.md", + outputFile: "/tmp/research-stdout.txt", + stderrFile: "/tmp/research-stderr.txt", + sentinelFile: "/tmp/research-done", + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/research-requirements.md", researchInput, + "/tmp/research-wrapper.sh", researchScript, + ); + + const researchDone = await pollUntilDone(sandboxId, "/tmp/research-done", 20); + if (!researchDone) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: research phase timed out`); + await unregisterRun(ticket.identifier); + return; + } + + const researchRaw = await collectPhaseOutput(sandboxId, "/tmp/research-stdout.txt", "/tmp/research-stderr.txt"); + const research = parseResearchStatus(researchRaw); + + if (research.status === "clarification_needed") { + const questions = research.body.split("\n").filter((l) => /^\d+\./.test(l.trim())); + await postClarificationAndMoveBack( + ticketId, + questions.length > 0 ? questions : [research.body], + env.COLUMN_BACKLOG, + ); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + if (research.status === "failed") { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: research — ${research.body.slice(0, 200)}`); + await unregisterRun(ticket.identifier); + return; + } + + const researchPlanMarkdown = research.body; + + // ========== PHASE 2 & 3 LOOP ========== + let reviewRetries = 0; + let lastReviewFeedback: ReviewOutput | undefined; + + while (true) { + // ========== PHASE 2: Implementation ========== + await configureStopHook(sandboxId, true); + + const implInput = lastReviewFeedback + ? assembleImplementationRetryContext({ + ticket: ticketData, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + reviewFeedback: lastReviewFeedback, + }) + : assembleImplementationContext({ + ticket: ticketData, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + }); + + const implScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: AGENT_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/impl-requirements.md", implInput, + "/tmp/impl-wrapper.sh", implScript, + ); + + const implDone = await pollUntilDone(sandboxId, "/tmp/impl-done", 35); + let implOutput: AgentOutput; + + if (implDone) { + const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); + implOutput = parseAgentOutput(implRaw); + } else { + implOutput = { result: "failed", error: "Implementation phase timed out" }; + } + + if (implOutput.result === "clarification_needed") { + await postClarificationAndMoveBack( + ticketId, + implOutput.questions ?? [], + env.COLUMN_BACKLOG, + ); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + if (implOutput.result === "failed") { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + // ========== PHASE 3: Review ========== + await configureStopHook(sandboxId, false); + + const gitDiff = await captureGitDiff(sandboxId); + + const reviewInput = assembleReviewContext({ + ticket: ticketData, + prompt: getPrompt("review.md"), + researchPlanMarkdown, + gitDiff, + }); + + const reviewScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "review", + inputFile: "/tmp/review-requirements.md", + outputFile: "/tmp/review-stdout.txt", + stderrFile: "/tmp/review-stderr.txt", + sentinelFile: "/tmp/review-done", + jsonSchema: REVIEW_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/review-requirements.md", reviewInput, + "/tmp/review-wrapper.sh", reviewScript, + ); + + const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); + let reviewOutput: ReviewOutput; + + if (reviewDone) { + const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); + reviewOutput = parseReviewOutput(reviewRaw); + } else { + reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; + } + + if (reviewOutput.result === "approved") { + break; // Exit loop → push + } + + if (reviewOutput.result === "changes_requested") { + reviewRetries++; + if (reviewRetries > MAX_REVIEW_RETRIES) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: review rejected after ${MAX_REVIEW_RETRIES} retries`); + await unregisterRun(ticket.identifier); + return; + } + lastReviewFeedback = reviewOutput; + continue; // Loop back to Phase 2 + } + + // result === "failed" + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + // ========== POST-PHASES: Push & PR ========== + let pushResult = await pushFromSandbox(sandboxId, branchName); + if (!pushResult.pushed && pushResult.error) { + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + } + + if (!pushResult.pushed) { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + if (!prContext) { + await createPullRequest(branchName, ticket.title, ""); + } + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + await notifySlack(`Task ${ticket.identifier} PR ready for review`); + await unregisterRun(ticket.identifier); + } finally { + await teardownSandbox(sandboxId); + } + } catch (err) { + console.error(`Workflow failed for ${ticket.identifier}:`, err); + const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); + await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + if (moved) { + await unregisterRun(ticket.identifier).catch(() => {}); + } else { + await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + } + throw err; + } +} diff --git a/src/workflows/implementation.ts b/src/workflows/implementation.ts deleted file mode 100644 index 26f8500..0000000 --- a/src/workflows/implementation.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { sleep } from "workflow"; -import type { AgentOutput } from "../sandbox/agent-runner.js"; -import type { TicketContent } from "../adapters/issue-tracker/types.js"; - -// --- Step Functions --- - -async function fetchAndValidateTicket(ticketId: string, columnAi: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { issueTracker } = createStepAdapters(); - const ticket = await issueTracker.fetchTicket(ticketId); - - if (ticket.trackerStatus.toLowerCase() !== columnAi.toLowerCase()) { - return null; - } - return ticket; -} - -async function createFeatureBranch(branchName: string, baseBranch: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { vcs } = createStepAdapters(); - await vcs.createBranch(branchName, baseBranch); -} - -async function assembleImplementationRequirements(ticket: TicketContent) { - "use step"; - const { assembleImplementationContext } = await import("../sandbox/context.js"); - const { getPrompt } = await import("../lib/prompts.js"); - - const prompt = getPrompt("implement.md"); - return assembleImplementationContext({ - ticket: { - identifier: ticket.identifier, - title: ticket.title, - description: ticket.description, - acceptanceCriteria: ticket.acceptanceCriteria, - comments: ticket.comments, - }, - prompt, - }); -} - -async function provisionAndStartAgent( - branchName: string, - requirementsMd: string, -): Promise { - "use step"; - const { env } = await import("../../env.js"); - const { SandboxManager } = await import("../sandbox/manager.js"); - const { startAgentDetached } = await import("../sandbox/run-agent.js"); - - const manager = new SandboxManager({ - githubToken: env.GITHUB_TOKEN, - owner: env.GITHUB_OWNER, - repo: env.GITHUB_REPO, - anthropicApiKey: env.ANTHROPIC_API_KEY, - claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, - claudeModel: env.CLAUDE_MODEL, - commitAuthor: env.COMMIT_AUTHOR, - commitEmail: env.COMMIT_EMAIL, - jobTimeoutMs: env.JOB_TIMEOUT_MS, - }); - - const sandbox = await manager.provision(branchName, requirementsMd); - await startAgentDetached(sandbox); - return sandbox.sandboxId; -} -provisionAndStartAgent.maxRetries = 0; - -async function createPullRequest( - branchName: string, - title: string, - summary: string, -) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { vcs } = createStepAdapters(); - return vcs.createPR(branchName, title, summary); -} - -async function moveTicket(ticketId: string, column: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { issueTracker } = createStepAdapters(); - await issueTracker.moveTicket(ticketId, column); -} - -async function notifySlack(message: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { messaging } = createStepAdapters(); - await messaging.notify(message); -} - -async function postClarificationAndMoveBack( - ticketId: string, - questions: string[], - identifier: string, - backlogColumn: string, -) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { issueTracker } = createStepAdapters(); - const comment = questions.map((q, i) => `${i + 1}. ${q}`).join("\n"); - await issueTracker.postComment(ticketId, comment); - await issueTracker.moveTicket(ticketId, backlogColumn); -} - -async function unregisterRun(ticketIdentifier: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { runRegistry } = createStepAdapters(); - await runRegistry.unregister(ticketIdentifier); -} - -async function markTicketFailed(ticketIdentifier: string, error: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { runRegistry } = createStepAdapters(); - const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; - await runRegistry.markFailed(ticketIdentifier, { - runId, - error, - failedAt: new Date().toISOString(), - }); -} - -// --- Workflow (durable orchestration — no I/O directly here) --- - -export async function implementationWorkflow(ticketId: string) { - "use workflow"; - - const { env } = await import("../../env.js"); - - const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); - if (!ticket) return; - - try { - await notifySlack(`Task ${ticket.identifier} started — implementing`); - - const branchName = `blazebot/${ticket.identifier.toLowerCase()}`; - await createFeatureBranch(branchName, env.GITHUB_BASE_BRANCH); - - const requirementsMd = await assembleImplementationRequirements(ticket); - - // --- Detached execution with polling --- - const { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = - await import("../sandbox/poll-agent.js"); - - const sandboxId = await provisionAndStartAgent(branchName, requirementsMd); - - // Poll until agent finishes — workflow truly suspends between polls. - // Use an iteration counter (not Date.now()) for deterministic WDK replay. - const POLL_INTERVAL = "30s"; - const MAX_POLLS = Math.ceil((35 * 60) / 30); // ~70 iterations ≈ 35 min - let pollCount = 0; - let agentDone = false; - - try { - while (!agentDone) { - await sleep(POLL_INTERVAL); - pollCount++; - - if (pollCount >= MAX_POLLS) break; - - const status = await checkAgentDone(sandboxId); - if (status === true) { - agentDone = true; - } else if (status === "stopped") { - break; - } - } - - let output: AgentOutput; - - if (agentDone) { - ({ output } = await collectAgentOutput(sandboxId)); - } else { - output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; - } - - if (output.result === "implemented") { - let pushResult = await pushFromSandbox(sandboxId, branchName); - - if (!pushResult.pushed && pushResult.error) { - pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); - } - - if (!pushResult.pushed) { - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); - await unregisterRun(ticket.identifier); - return; - } - - await createPullRequest(branchName, ticket.title, output.summary ?? ""); - await moveTicket(ticketId, env.COLUMN_AI_REVIEW); - await notifySlack(`Task ${ticket.identifier} PR ready for review`); - await unregisterRun(ticket.identifier); - return; - } - - if (output.result === "clarification_needed") { - await postClarificationAndMoveBack( - ticketId, - output.questions ?? [], - ticket.identifier, - env.COLUMN_BACKLOG, - ); - await notifySlack(`Task ${ticket.identifier} needs clarification`); - await unregisterRun(ticket.identifier); - return; - } - - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: ${output.error ?? "unknown error"}`); - await unregisterRun(ticket.identifier); - } finally { - await teardownSandbox(sandboxId); - } - } catch (err) { - console.error(`Workflow failed for ${ticket.identifier}:`, err); - const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); - await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - if (moved) { - await unregisterRun(ticket.identifier).catch(() => {}); - } else { - await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - } - throw err; - } -} diff --git a/src/workflows/review-fix.ts b/src/workflows/review-fix.ts deleted file mode 100644 index c27ce6b..0000000 --- a/src/workflows/review-fix.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { FatalError } from "workflow"; -import { sleep } from "workflow"; -import type { AgentOutput } from "../sandbox/agent-runner.js"; -import type { TicketContent } from "../adapters/issue-tracker/types.js"; -import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; - -// --- Step Functions --- - -async function fetchAndValidateTicket(ticketId: string, columnAi: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { issueTracker } = createStepAdapters(); - const ticket = await issueTracker.fetchTicket(ticketId); - - if (ticket.trackerStatus.toLowerCase() !== columnAi.toLowerCase()) { - return null; - } - return ticket; -} - -async function fetchPRContext(branchName: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { vcs } = createStepAdapters(); - const pr = await vcs.findPR(branchName); - if (!pr) throw new FatalError(`No open PR found for branch ${branchName}`); - - const comments = await vcs.getPRComments(pr.id); - const hasConflicts = await vcs.getPRConflictStatus(pr.id); - const checkResults = await vcs.getCheckRunResults(pr.id); - - return { pr, comments, hasConflicts, checkResults }; -} - -async function assembleReviewFixRequirements( - ticket: TicketContent, - prComments: PRComment[], - hasConflicts: boolean, - checkResults: CheckRunResult[], -) { - "use step"; - const { assembleFixingFeedbackContext } = - await import("../sandbox/context.js"); - const { getPrompt } = await import("../lib/prompts.js"); - - const prompt = getPrompt("review-fix.md"); - return assembleFixingFeedbackContext({ - ticket: { - identifier: ticket.identifier, - title: ticket.title, - description: ticket.description, - acceptanceCriteria: ticket.acceptanceCriteria, - comments: ticket.comments, - }, - prompt, - prComments, - hasConflicts, - checkResults, - }); -} - -async function provisionAndStartFixingAgent( - branchName: string, - requirementsMd: string, - mergeBase: string, -): Promise { - "use step"; - const { env } = await import("../../env.js"); - const { SandboxManager } = await import("../sandbox/manager.js"); - const { startAgentDetached } = await import("../sandbox/run-agent.js"); - - const manager = new SandboxManager({ - githubToken: env.GITHUB_TOKEN, - owner: env.GITHUB_OWNER, - repo: env.GITHUB_REPO, - anthropicApiKey: env.ANTHROPIC_API_KEY, - claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, - claudeModel: env.CLAUDE_MODEL, - commitAuthor: env.COMMIT_AUTHOR, - commitEmail: env.COMMIT_EMAIL, - jobTimeoutMs: env.JOB_TIMEOUT_MS, - }); - - const sandbox = await manager.provision( - branchName, - requirementsMd, - mergeBase, - ); - await startAgentDetached(sandbox); - return sandbox.sandboxId; -} -provisionAndStartFixingAgent.maxRetries = 0; - -async function moveTicket(ticketId: string, column: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { issueTracker } = createStepAdapters(); - await issueTracker.moveTicket(ticketId, column); -} - -async function notifySlack(message: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { messaging } = createStepAdapters(); - await messaging.notify(message); -} - -async function unregisterRun(ticketIdentifier: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { runRegistry } = createStepAdapters(); - await runRegistry.unregister(ticketIdentifier); -} - -async function markTicketFailed(ticketIdentifier: string, error: string) { - "use step"; - const { createStepAdapters } = await import("../lib/step-adapters.js"); - const { runRegistry } = createStepAdapters(); - const runId = await runRegistry.getRunId(ticketIdentifier) ?? "unknown"; - await runRegistry.markFailed(ticketIdentifier, { - runId, - error, - failedAt: new Date().toISOString(), - }); -} - -// --- Workflow --- - -export async function reviewFixWorkflow(ticketId: string, branchName: string) { - "use workflow"; - - const { env } = await import("../../env.js"); - - const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); - if (!ticket) return; - - try { - await notifySlack( - `Task ${ticket.identifier} started — fixing review feedback`, - ); - - const { comments, hasConflicts, checkResults } = await fetchPRContext(branchName); - - const requirementsMd = await assembleReviewFixRequirements( - ticket, - comments, - hasConflicts, - checkResults, - ); - - // --- Detached execution with polling --- - const { checkAgentDone, collectAgentOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = - await import("../sandbox/poll-agent.js"); - - const sandboxId = await provisionAndStartFixingAgent( - branchName, - requirementsMd, - env.GITHUB_BASE_BRANCH, - ); - - // Poll until agent finishes — use iteration counter for deterministic WDK replay. - const POLL_INTERVAL = "30s"; - const MAX_POLLS = Math.ceil((35 * 60) / 30); // ~70 iterations ≈ 35 min - let pollCount = 0; - let agentDone = false; - - try { - while (!agentDone) { - await sleep(POLL_INTERVAL); - pollCount++; - - if (pollCount >= MAX_POLLS) break; - - const status = await checkAgentDone(sandboxId); - if (status === true) { - agentDone = true; - } else if (status === "stopped") { - break; - } - } - - let output: AgentOutput; - - if (agentDone) { - ({ output } = await collectAgentOutput(sandboxId)); - } else { - output = { result: "failed", error: "Agent timed out or sandbox stopped unexpectedly" }; - } - - if (output.result === "implemented") { - let pushResult = await pushFromSandbox(sandboxId, branchName); - - if (!pushResult.pushed && pushResult.error) { - pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); - } - - if (!pushResult.pushed) { - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); - await unregisterRun(ticket.identifier); - return; - } - - await moveTicket(ticketId, env.COLUMN_AI_REVIEW); - await notifySlack( - `Task ${ticket.identifier} fixes applied, ready for re-review`, - ); - await unregisterRun(ticket.identifier); - return; - } - - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack( - `Task ${ticket.identifier} review-fix failed: ${output.error ?? "unknown error"}`, - ); - await unregisterRun(ticket.identifier); - } finally { - await teardownSandbox(sandboxId); - } - } catch (err) { - console.error(`Workflow failed for ${ticket.identifier}:`, err); - const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG) - .then(() => true) - .catch(() => false); - await notifySlack( - `Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`, - ).catch(() => {}); - if (moved) { - await unregisterRun(ticket.identifier).catch(() => {}); - } else { - await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); - } - throw err; - } -} From de889ce0a9aab6113fdd217c64725a7ab8bccd0b Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 7 Apr 2026 12:51:24 +0200 Subject: [PATCH 08/71] feat: prevent gitignoing memory --- src/lib/prompts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 6f4127d..e954dc1 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -112,6 +112,7 @@ You have access to **superpowers skills** installed globally. Use them. - Do not refactor code outside the scope of the plan. - Do not install new dependencies unless the plan specifies them. - Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). +- Do NOT add \`blazebot/memory\` to \`.gitignore\` unless the user explicitly asks you to. Session memory must be committed to the branch. - Do NOT invoke \`requesting-code-review\` — that happens in a separate review phase. ## When to Ask for Clarification From 348c3ebb21f4a4d9a8a7ab7c5b9fab3b0aecdee6 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 7 Apr 2026 15:35:46 +0200 Subject: [PATCH 09/71] feat: add token usage --- src/sandbox/usage.test.ts | 152 +++++++++++++++++++++++++++++ src/sandbox/usage.ts | 115 ++++++++++++++++++++++ src/sandbox/wrapper-script.test.ts | 2 +- src/sandbox/wrapper-script.ts | 4 +- src/workflows/agent.ts | 14 ++- 5 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 src/sandbox/usage.test.ts create mode 100644 src/sandbox/usage.ts diff --git a/src/sandbox/usage.test.ts b/src/sandbox/usage.test.ts new file mode 100644 index 0000000..dfde69e --- /dev/null +++ b/src/sandbox/usage.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { extractUsage, unwrapResearchText, formatUsageReport, type PhaseUsage } from "./usage.js"; + +describe("extractUsage", () => { + it("extracts usage from a single JSON result envelope", () => { + const raw = JSON.stringify({ + type: "result", + subtype: "success", + cost_usd: 0.053, + duration_ms: 120000, + duration_api_ms: 45000, + num_turns: 15, + result: "STATUS: completed\n\nPlan here", + }); + const usage = extractUsage(raw); + expect(usage).toEqual({ + cost_usd: 0.053, + duration_ms: 120000, + duration_api_ms: 45000, + num_turns: 15, + }); + }); + + it("extracts usage from stream-json with multiple lines", () => { + const lines = [ + JSON.stringify({ type: "assistant", content: "Working on it..." }), + JSON.stringify({ + type: "result", + subtype: "success", + cost_usd: 0.08, + duration_ms: 200000, + duration_api_ms: 60000, + num_turns: 10, + structured_output: { result: "implemented", summary: "Done" }, + }), + ]; + const usage = extractUsage(lines.join("\n")); + expect(usage).toEqual({ + cost_usd: 0.08, + duration_ms: 200000, + duration_api_ms: 60000, + num_turns: 10, + }); + }); + + it("uses total_cost_usd when cost_usd is missing", () => { + const raw = JSON.stringify({ + type: "result", + subtype: "success", + total_cost_usd: 0.12, + duration_ms: 50000, + duration_api_ms: 30000, + num_turns: 5, + result: "done", + }); + const usage = extractUsage(raw); + expect(usage?.cost_usd).toBe(0.12); + }); + + it("returns null for empty input", () => { + expect(extractUsage("")).toBeNull(); + expect(extractUsage(" ")).toBeNull(); + }); + + it("returns null for plain text without envelope", () => { + expect(extractUsage("STATUS: completed\n\nSome plan")).toBeNull(); + }); + + it("returns null for JSON without cost fields", () => { + const raw = JSON.stringify({ type: "result", subtype: "success", result: "ok" }); + expect(extractUsage(raw)).toBeNull(); + }); + + it("defaults missing duration/turns to 0", () => { + const raw = JSON.stringify({ + type: "result", + subtype: "success", + cost_usd: 0.01, + result: "ok", + }); + const usage = extractUsage(raw); + expect(usage).toEqual({ + cost_usd: 0.01, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 0, + }); + }); +}); + +describe("unwrapResearchText", () => { + it("extracts result text from JSON envelope", () => { + const raw = JSON.stringify({ + type: "result", + subtype: "success", + cost_usd: 0.05, + result: "STATUS: completed\n\n# Plan\n1. Do stuff", + }); + const text = unwrapResearchText(raw); + expect(text).toBe("STATUS: completed\n\n# Plan\n1. Do stuff"); + }); + + it("returns plain text as-is when no envelope", () => { + const raw = "STATUS: completed\n\nPlan here"; + expect(unwrapResearchText(raw)).toBe(raw); + }); + + it("returns empty string for empty input", () => { + expect(unwrapResearchText("")).toBe(""); + }); + + it("returns raw when envelope has non-string result", () => { + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: { nested: true }, + }); + expect(unwrapResearchText(raw)).toBe(raw); + }); +}); + +describe("formatUsageReport", () => { + it("formats multiple phases with total", () => { + const phases: Record = { + Research: { cost_usd: 0.03, duration_ms: 120000, duration_api_ms: 45000, num_turns: 10 }, + Impl: { cost_usd: 0.10, duration_ms: 900000, duration_api_ms: 300000, num_turns: 25 }, + Review: { cost_usd: 0.02, duration_ms: 180000, duration_api_ms: 60000, num_turns: 5 }, + }; + const report = formatUsageReport(phases); + expect(report).toContain("$0.15 total"); + expect(report).toContain("Research: $0.03 (2m)"); + expect(report).toContain("Impl: $0.10 (15m)"); + expect(report).toContain("Review: $0.02 (3m)"); + }); + + it("shows n/a for phases with null usage", () => { + const phases: Record = { + Research: null, + Impl: { cost_usd: 0.05, duration_ms: 60000, duration_api_ms: 30000, num_turns: 3 }, + }; + const report = formatUsageReport(phases); + expect(report).toContain("Research: n/a"); + expect(report).toContain("Impl: $0.05 (1m)"); + expect(report).toContain("$0.05 total"); + }); + + it("handles all null phases", () => { + const report = formatUsageReport({ Research: null, Impl: null }); + expect(report).toContain("$0.00 total"); + expect(report).toContain("Research: n/a"); + }); +}); diff --git a/src/sandbox/usage.ts b/src/sandbox/usage.ts new file mode 100644 index 0000000..c83d835 --- /dev/null +++ b/src/sandbox/usage.ts @@ -0,0 +1,115 @@ +/** + * Extracts Claude Code usage/cost data from the JSON result envelope + * that `claude --print --output-format json` outputs. + */ + +export interface PhaseUsage { + cost_usd: number; + duration_ms: number; + duration_api_ms: number; + num_turns: number; +} + +/** + * Scans raw agent output for a Claude Code result envelope and extracts cost fields. + * Works with both single-object JSON and stream-json (newline-delimited) formats. + * Returns null if no usage data is found (e.g. agent crashed before producing output). + */ +export function extractUsage(raw: string): PhaseUsage | null { + if (!raw.trim()) return null; + + // Try single JSON object first (--output-format json) + const envelope = findResultEnvelope(raw); + if (!envelope) return null; + + const cost = + typeof envelope.cost_usd === "number" + ? envelope.cost_usd + : typeof envelope.total_cost_usd === "number" + ? envelope.total_cost_usd + : null; + if (cost === null) return null; + + return { + cost_usd: cost, + duration_ms: + typeof envelope.duration_ms === "number" ? envelope.duration_ms : 0, + duration_api_ms: + typeof envelope.duration_api_ms === "number" + ? envelope.duration_api_ms + : 0, + num_turns: typeof envelope.num_turns === "number" ? envelope.num_turns : 0, + }; +} + +/** + * Unwraps the text content from a Claude Code JSON result envelope. + * Used for the research phase which outputs free-form text (no --json-schema). + * + * If the raw output is already plain text (no envelope), returns it as-is. + */ +export function unwrapResearchText(raw: string): string { + if (!raw.trim()) return raw; + + const envelope = findResultEnvelope(raw); + if (!envelope) return raw; + + // The text content lives in the `result` field of the envelope + if (typeof envelope.result === "string") { + return envelope.result; + } + + // Fallback: return raw (shouldn't happen with --output-format json) + return raw; +} + +/** + * Formats accumulated phase usage data into a compact Slack-friendly string. + */ +export function formatUsageReport( + phases: Record, +): string { + const parts: string[] = []; + let totalCost = 0; + + for (const [name, usage] of Object.entries(phases)) { + if (!usage) { + parts.push(`${name}: n/a`); + continue; + } + totalCost += usage.cost_usd; + const mins = Math.round(usage.duration_ms / 60_000); + parts.push(`${name}: $${usage.cost_usd.toFixed(2)} (${mins}m)`); + } + + return `Usage: $${totalCost.toFixed(2)} total | ${parts.join(" | ")}`; +} + +// --- Internal --- + +function findResultEnvelope(raw: string): Record | null { + // Try parsing as a single JSON object + try { + const obj = JSON.parse(raw); + if (obj && typeof obj === "object" && obj.type === "result") { + return obj as Record; + } + } catch { + // Not a single JSON object — try line-by-line + } + + // Scan lines in reverse for a result envelope (stream-json format) + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const obj = JSON.parse(lines[i]); + if (obj && typeof obj === "object" && obj.type === "result") { + return obj as Record; + } + } catch { + // Not valid JSON, try next line + } + } + + return null; +} diff --git a/src/sandbox/wrapper-script.test.ts b/src/sandbox/wrapper-script.test.ts index 3c5eac5..487cb21 100644 --- a/src/sandbox/wrapper-script.test.ts +++ b/src/sandbox/wrapper-script.test.ts @@ -20,7 +20,7 @@ describe("buildPhaseScript", () => { expect(script).toContain("/tmp/research-stderr.txt"); expect(script).toContain("/tmp/research-done"); expect(script).not.toContain("--json-schema"); - expect(script).not.toContain("--output-format"); + expect(script).toContain("--output-format json"); }); it("generates impl phase script with json-schema", () => { diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts index 28b93b6..ed3441a 100644 --- a/src/sandbox/wrapper-script.ts +++ b/src/sandbox/wrapper-script.ts @@ -15,11 +15,11 @@ export interface PhaseScriptOptions { export function buildPhaseScript(opts: PhaseScriptOptions): string { const { model, inputFile, outputFile, stderrFile, sentinelFile, jsonSchema } = opts; - let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions`; + let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions --output-format json`; if (jsonSchema) { const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); - claudeFlags += ` --output-format json --json-schema '${escapedSchema}'`; + claudeFlags += ` --json-schema '${escapedSchema}'`; } return `#!/bin/bash diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 56a0de9..04d8781 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -2,6 +2,7 @@ import { sleep } from "workflow"; import type { AgentOutput } from "../sandbox/agent-runner.js"; import type { ReviewOutput } from "../sandbox/agent-runner.js"; import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; +import type { PhaseUsage } from "../sandbox/usage.js"; // --- Step Functions --- @@ -210,6 +211,8 @@ export async function agentWorkflow(ticketId: string) { await import("../sandbox/context.js"); const { collectPhaseOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); + const { extractUsage, unwrapResearchText, formatUsageReport } = + await import("../sandbox/usage.js"); const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return; @@ -280,7 +283,8 @@ export async function agentWorkflow(ticketId: string) { } const researchRaw = await collectPhaseOutput(sandboxId, "/tmp/research-stdout.txt", "/tmp/research-stderr.txt"); - const research = parseResearchStatus(researchRaw); + const researchUsage = extractUsage(researchRaw); + const research = parseResearchStatus(unwrapResearchText(researchRaw)); if (research.status === "clarification_needed") { const questions = research.body.split("\n").filter((l) => /^\d+\./.test(l.trim())); @@ -304,6 +308,7 @@ export async function agentWorkflow(ticketId: string) { const researchPlanMarkdown = research.body; // ========== PHASE 2 & 3 LOOP ========== + const phaseUsages: Record = { Research: researchUsage }; let reviewRetries = 0; let lastReviewFeedback: ReviewOutput | undefined; @@ -345,6 +350,8 @@ export async function agentWorkflow(ticketId: string) { if (implDone) { const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); + const implLabel = reviewRetries > 0 ? `Impl retry ${reviewRetries}` : "Impl"; + phaseUsages[implLabel] = extractUsage(implRaw); implOutput = parseAgentOutput(implRaw); } else { implOutput = { result: "failed", error: "Implementation phase timed out" }; @@ -401,6 +408,8 @@ export async function agentWorkflow(ticketId: string) { if (reviewDone) { const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); + const reviewLabel = reviewRetries > 0 ? `Review retry ${reviewRetries}` : "Review"; + phaseUsages[reviewLabel] = extractUsage(reviewRaw); reviewOutput = parseReviewOutput(reviewRaw); } else { reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; @@ -446,7 +455,8 @@ export async function agentWorkflow(ticketId: string) { await createPullRequest(branchName, ticket.title, ""); } await moveTicket(ticketId, env.COLUMN_AI_REVIEW); - await notifySlack(`Task ${ticket.identifier} PR ready for review`); + const usageReport = formatUsageReport(phaseUsages); + await notifySlack(`Task ${ticket.identifier} PR ready for review\n${usageReport}`); await unregisterRun(ticket.identifier); } finally { await teardownSandbox(sandboxId); From e8003775ba68d4c6618b158ba237090f774d73a5 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 06:42:20 +0200 Subject: [PATCH 10/71] docs: add security observability spec for Arthur Engine integration Defines 6 threat categories (prompt injection, data exfiltration, secrets leakage, PII, code safety, behavioral anomalies), pipeline integration gates, response severity model, and observability streams for the AWS on-prem deployment. Co-Authored-By: Claude Opus 4.6 --- ...026-04-09-security-observability-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-security-observability-design.md diff --git a/docs/superpowers/specs/2026-04-09-security-observability-design.md b/docs/superpowers/specs/2026-04-09-security-observability-design.md new file mode 100644 index 0000000..a136e9e --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-security-observability-design.md @@ -0,0 +1,200 @@ +# Security Observability Spec + +Security observability for the AI workflow system (AWS on-prem). Monitors LLM behavior using Arthur Engine for content analysis, AWS-native tooling for network monitoring, and custom Nitro logic for behavioral anomalies. + +Target deployment: AWS on-prem architecture (Fargate agents, EC2 Nitro server). + +## Threat Categories + +### 1. Prompt Injection + +Detect adversarial instructions injected into the LLM context from untrusted sources. + +**Vectors:** + +- Jira ticket descriptions and comments — scanned before they become part of the prompt (pre-phase input gate) +- WebFetch responses — every fetched web page scanned for injection patterns before entering LLM context (during agent execution) +- PR review comments — scanned before the review-fix cycle begins (pre-phase input gate) + +**Detection:** Arthur Engine Prompt Injection evaluation (DeBERTa v3 classifier). + +**Response:** Critical — kill sandbox, cancel workflow, move ticket to "Security Review" Jira column, Slack alert. + +### 2. Data Exfiltration & Network Monitoring + +Detect unauthorized outbound communication from agent sandboxes. The primary fear: a prompt-injected agent exfiltrating source code or secrets to an attacker-controlled server. + +**Checks:** + +- Outbound connections — every TCP connection from Fargate agents via VPC Flow Logs on `sg-fargate` +- DNS queries — domain names the agent resolves via VPC DNS query logging +- Traffic volume — bytes uploaded per connection via VPC Flow Logs aggregation +- Unauthorized endpoints — connections to IPs/domains outside GitHub + Anthropic API +- Large uploads — unusual outbound data volume (e.g., >10MB to a single IP) + +**Detection:** AWS-native — VPC Flow Logs, DNS query logging, CloudWatch metric filters. + +**Response:** Unauthorized endpoint → critical (kill sandbox). Volume anomaly → medium (flag + alert). + +### 3. Secrets & Credential Leakage + +Prevent API keys, tokens, passwords, and connection strings from appearing in generated code, logs, or PRs. + +**Checks:** + +- Generated code — scan for hardcoded secrets post-phase (output gate) +- Agent stdout/stderr — scan for accidentally logged secrets post-phase (output gate) +- PR diff — final secrets scan on the full diff before pushing to GitHub (pre-push gate) +- Prompt content — detect secrets from environment variables leaking into prompts (pre-phase input gate) + +**Detection:** Arthur Engine PII Detection (Presidio) + custom regex rules for secret patterns (AWS access keys, GitHub tokens, JWTs, database connection strings, private keys). + +**Response:** Critical — no PR created, sandbox killed, ticket to "Security Review", Slack alert with redacted details. + +### 4. PII & Sensitive Business Data + +Detect personally identifiable information and confidential business data in inputs and outputs. + +**Checks:** + +- Jira ticket content — flag customer PII that shouldn't reach the LLM (pre-phase input gate) +- LLM responses — detect PII or confidential business logic in outputs (post-phase output gate) +- Generated code — detect hardcoded customer data, internal URLs, internal IPs (post-phase output gate) + +**Detection:** Arthur Engine PII Detection (Presidio) for standard PII (names, emails, SSNs, credit cards). Arthur Engine Sensitive Data evaluation (few-shot LLM Judge) for business-specific confidential data — requires custom examples defining what "sensitive" means for the organization. + +**Response:** Critical PII (SSN, credit card) → high (block PR). Other PII/sensitive data → medium (PR with `security-review` label + comment). + +### 5. Code Safety & Vulnerability Detection + +Detect common security vulnerabilities in LLM-generated code. + +**Checks:** + +- SQL injection patterns in generated database queries +- Command injection via shell commands, exec calls +- XSS vulnerabilities from unsanitized user input rendering +- Insecure dependencies — newly added packages with known CVEs +- Hardcoded secrets (overlaps with threat 3) + +**Detection:** Arthur Engine custom rules (keyword + regex) for OWASP Top 10 patterns. Potential future integration with a dedicated SAST tool for deeper analysis. + +**Response:** Medium — PR created with `security-review` label + comment describing the specific vulnerability. + +### 6. Behavioral Anomalies + +Detect unusual agent behavior that may indicate compromise or malfunction. + +**Checks:** + +- Tool usage patterns — unusual number of WebFetch calls, unexpected tool sequences (CloudWatch logs) +- Execution duration — agent running significantly longer than expected (poll loop) +- Output size anomalies — unusually large or small output for the task type (post-phase) +- Retry storms — same ticket failing repeatedly with different errors (cross-run, Redis registry) +- Token usage spikes — cost per phase significantly above baseline (usage extraction) + +**Detection:** Custom Nitro-side logic — threshold-based alerts on CloudWatch metrics and workflow metadata. + +**Response:** Low — log only, included in Slack usage report. + +## Pipeline Integration + +Checks hook into the existing workflow pipeline at four gates: + +``` +Jira Ticket Discovered + | + v ++-----------------------------+ +| INPUT GATE | +| - Prompt injection scan | +| - PII detection | +| - Secrets in ticket content | ++-------------+---------------+ + | pass + v ++-----------------------------+ +| RESEARCH PHASE (Fargate) | +| | +| Runtime monitoring: | +| - VPC Flow Logs (network) | +| - DNS query logging | +| - CloudWatch (tool usage) | +| | +| WebFetch interception: | +| - Scan fetched content for | +| prompt injection before | +| it enters LLM context | ++-------------+---------------+ + | + v ++-----------------------------+ +| OUTPUT GATE (post-research) | +| - Secrets scan | +| - PII scan | +| - Behavioral anomaly check | ++-------------+---------------+ + | pass + v ++-----------------------------+ +| IMPLEMENTATION PHASE | +| (same runtime monitoring) | ++-------------+---------------+ + | + v ++-----------------------------+ +| OUTPUT GATE (post-impl) | +| - All output checks | +| - Code safety (OWASP) | +| - Secrets in generated code | ++-------------+---------------+ + | pass + v ++-----------------------------+ +| PRE-PUSH GATE | +| - Final secrets scan on | +| full PR diff | +| - Final PII check | ++-------------+---------------+ + | pass + v + Push to GitHub + Create PR (with any soft-flag labels) +``` + +**Codebase integration points:** + +- Input gate — in `agentWorkflow` before `writeAndStartPhase` +- WebFetch interception — hook/proxy inside the agent container +- Runtime monitoring — AWS-native (VPC Flow Logs, DNS logs, CloudWatch) +- Output gate — in `collectPhaseOutput` before returning results +- Pre-push gate — in `pushChanges` before GitHub API calls + +## Response Model + +Four severity tiers with escalation: + +| Severity | Triggers | Action | +|----------|----------|--------| +| Critical | Prompt injection detected, secrets in output, unauthorized network connection, data exfiltration | Kill sandbox, cancel workflow, move ticket to "Security Review" column, Slack alert | +| High | PII in generated code (SSN, credit card), OWASP vulnerability patterns, sensitive business data leak | Block PR creation, move ticket to "Security Review", Slack alert | +| Medium | PII in inputs (Jira ticket), mild anomalies in tool usage, elevated token spend | Create PR with `security-review` label + comment describing the finding, Slack notification | +| Low | Minor behavioral anomalies (long duration, unusual output size) | Log only, included in Slack usage report | + +**Escalation rule:** If the same ticket triggers 2+ medium findings across phases, auto-escalate to high (block PR). + +## Observability Streams + +Three independent streams unified by Slack alerting: + +| Stream | Tool | Scope | +|--------|------|-------| +| Content analysis | Arthur Engine | Prompts, responses, generated code — prompt injection, PII, secrets, toxicity, code safety | +| Network monitoring | AWS (VPC Flow Logs, DNS query logging, CloudWatch) | Outbound connections, DNS resolution, traffic volume, unauthorized endpoints | +| Behavioral analysis | Custom Nitro logic | Tool usage patterns, execution duration, output size, retry storms, token usage | + +## WebFetch Strategy + +No domain allowlist. The agent can fetch any URL during research. All fetched content is scanned for prompt injection patterns by Arthur Engine before it enters the LLM context. If injection is detected, the sandbox is killed (critical severity). + +Rationale: a strict allowlist would break the research phase since the agent needs to read arbitrary documentation, Stack Overflow, npm registries, etc. From 862fa405dd2683be974f82397edfd8c93cf78d5d Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 07:25:48 +0200 Subject: [PATCH 11/71] docs: add debug mode section to security observability spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-demand deep inspection for suspicious agent runs — full prompt/response logging, tool call traces, network packet capture (tcpdump), per-tool-call file system diffs, and env dumps. Triggered manually or auto-escalated from medium+ findings. Co-Authored-By: Claude Opus 4.6 --- ...026-04-09-security-observability-design.md | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-04-09-security-observability-design.md b/docs/superpowers/specs/2026-04-09-security-observability-design.md index a136e9e..dc5ae87 100644 --- a/docs/superpowers/specs/2026-04-09-security-observability-design.md +++ b/docs/superpowers/specs/2026-04-09-security-observability-design.md @@ -1,6 +1,6 @@ # Security Observability Spec -Security observability for the AI workflow system (AWS on-prem). Monitors LLM behavior using Arthur Engine for content analysis, AWS-native tooling for network monitoring, and custom Nitro logic for behavioral anomalies. +Security observability for the AI workflow system (AWS on-prem). Target deployment: AWS on-prem architecture (Fargate agents, EC2 Nitro server). @@ -174,12 +174,12 @@ Jira Ticket Discovered Four severity tiers with escalation: -| Severity | Triggers | Action | -|----------|----------|--------| -| Critical | Prompt injection detected, secrets in output, unauthorized network connection, data exfiltration | Kill sandbox, cancel workflow, move ticket to "Security Review" column, Slack alert | -| High | PII in generated code (SSN, credit card), OWASP vulnerability patterns, sensitive business data leak | Block PR creation, move ticket to "Security Review", Slack alert | -| Medium | PII in inputs (Jira ticket), mild anomalies in tool usage, elevated token spend | Create PR with `security-review` label + comment describing the finding, Slack notification | -| Low | Minor behavioral anomalies (long duration, unusual output size) | Log only, included in Slack usage report | +| Severity | Triggers | Action | +| -------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Critical | Prompt injection detected, secrets in output, unauthorized network connection, data exfiltration | Kill sandbox, cancel workflow, move ticket to "Security Review" column, Slack alert | +| High | PII in generated code (SSN, credit card), OWASP vulnerability patterns, sensitive business data leak | Block PR creation, move ticket to "Security Review", Slack alert | +| Medium | PII in inputs (Jira ticket), mild anomalies in tool usage, elevated token spend | Create PR with `security-review` label + comment describing the finding, Slack notification | +| Low | Minor behavioral anomalies (long duration, unusual output size) | Log only, included in Slack usage report | **Escalation rule:** If the same ticket triggers 2+ medium findings across phases, auto-escalate to high (block PR). @@ -187,14 +187,40 @@ Four severity tiers with escalation: Three independent streams unified by Slack alerting: -| Stream | Tool | Scope | -|--------|------|-------| -| Content analysis | Arthur Engine | Prompts, responses, generated code — prompt injection, PII, secrets, toxicity, code safety | -| Network monitoring | AWS (VPC Flow Logs, DNS query logging, CloudWatch) | Outbound connections, DNS resolution, traffic volume, unauthorized endpoints | -| Behavioral analysis | Custom Nitro logic | Tool usage patterns, execution duration, output size, retry storms, token usage | +| Stream | Tool | Scope | +| ------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Content analysis | Arthur Engine | Prompts, responses, generated code — prompt injection, PII, secrets, toxicity, code safety | +| Network monitoring | AWS (VPC Flow Logs, DNS query logging, CloudWatch) | Outbound connections, DNS resolution, traffic volume, unauthorized endpoints | +| Behavioral analysis | Custom Nitro logic | Tool usage patterns, execution duration, output size, retry storms, token usage | ## WebFetch Strategy No domain allowlist. The agent can fetch any URL during research. All fetched content is scanned for prompt injection patterns by Arthur Engine before it enters the LLM context. If injection is detected, the sandbox is killed (critical severity). Rationale: a strict allowlist would break the research phase since the agent needs to read arbitrary documentation, Stack Overflow, npm registries, etc. + +## Debug Mode + +On-demand deep inspection mode for investigating suspicious agent behavior or post-incident analysis. Not always-on — toggled per ticket or per workflow run to avoid performance/cost overhead. + +**Activation:** Set `DEBUG_MODE=true` as an env override on the ECS RunTask call. Can be triggered: + +- Manually — operator flags a ticket ID for debug mode via Slack command or API call +- Automatically — when a medium+ severity finding triggers, the next retry (if any) runs in debug mode + +**What debug mode enables:** + +| Capability | What it captures | How | +|---|---|---| +| Full prompt logging | Complete prompts sent to Claude, including system prompt, ticket context, and all tool results | Capture stdin of the `claude --print` command | +| Full response logging | Complete LLM responses, including reasoning and tool calls | Already captured via stdout — debug mode preserves the raw unredacted output | +| Tool call trace | Every tool invocation (WebFetch URLs, file reads, file writes, bash commands) with timestamps | Parse Claude Code JSON output for tool-use events | +| Network packet capture | Full TCP payload logging for all outbound connections from the container | Run `tcpdump` as a sidecar process in the Fargate task, write pcap to EFS | +| File system diff | Snapshot of all file changes at each tool call, not just the final diff | Git commit after each tool call via Claude Code end-hook, inspect the commit log post-run | +| Environment dump | All non-secret env vars visible to the agent at startup | Log at container entry point, before claude starts | + +**Data retention:** Debug artifacts are written to `/workspace/$RUN_ID/.debug/` on EFS. Retained for 7 days (configurable via `DEBUG_RETENTION_DAYS`), then cleaned up by the daily EFS sweep. + +**Access control:** Debug output may contain sensitive data (full prompts, network payloads). Access restricted to operators with security review permissions. Debug artifacts are never included in Slack notifications — only a link to the EFS path. + +**Performance impact:** Network packet capture and per-tool-call git commits add overhead. Expected ~10-20% increase in phase duration. This is acceptable since debug mode is not always-on. From a01f9aed78b36ae99d7a16673529a93760051839 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 07:52:52 +0200 Subject: [PATCH 12/71] dcos: add security & observability spec --- ...026-04-09-security-observability-design.md | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-04-09-security-observability-design.md b/docs/superpowers/specs/2026-04-09-security-observability-design.md index dc5ae87..a4e4abc 100644 --- a/docs/superpowers/specs/2026-04-09-security-observability-design.md +++ b/docs/superpowers/specs/2026-04-09-security-observability-design.md @@ -28,6 +28,7 @@ Detect unauthorized outbound communication from agent sandboxes. The primary fea - Outbound connections — every TCP connection from Fargate agents via VPC Flow Logs on `sg-fargate` - DNS queries — domain names the agent resolves via VPC DNS query logging +- DNS tunneling — detect data exfiltration via DNS by monitoring for unusually long subdomain labels (>50 chars), high query volume to a single domain (>100 queries/min), and TXT record queries to non-standard domains - Traffic volume — bytes uploaded per connection via VPC Flow Logs aggregation - Unauthorized endpoints — connections to IPs/domains outside GitHub + Anthropic API - Large uploads — unusual outbound data volume (e.g., >10MB to a single IP) @@ -79,7 +80,7 @@ Detect common security vulnerabilities in LLM-generated code. **Detection:** Arthur Engine custom rules (keyword + regex) for OWASP Top 10 patterns. Potential future integration with a dedicated SAST tool for deeper analysis. -**Response:** Medium — PR created with `security-review` label + comment describing the specific vulnerability. +**Response:** High — block PR creation, move ticket to "Security Review", Slack alert. ### 6. Behavioral Anomalies @@ -95,6 +96,18 @@ Detect unusual agent behavior that may indicate compromise or malfunction. **Detection:** Custom Nitro-side logic — threshold-based alerts on CloudWatch metrics and workflow metadata. +**Initial thresholds (static, tuned from first month of production data):** + +| Metric | Threshold | Baseline assumption | +|---|---|---| +| WebFetch calls per phase | >30 | Typical research phase: 5-15 fetches | +| Phase execution duration | >20 min (research), >30 min (implementation) | Median: ~8 min research, ~15 min implementation | +| Output size | >500KB or <100 bytes | Typical: 1-50KB | +| Retry count per ticket | >3 within 24h | Most tickets succeed in 1-2 attempts | +| Token usage per phase | >200k tokens | Median: ~50-80k tokens | + +Thresholds are static at launch. After 30 days of production data, revisit and consider adaptive baselines derived from rolling 7-day percentiles (p95). + **Response:** Low — log only, included in Slack usage report. ## Pipeline Integration @@ -151,10 +164,28 @@ Jira Ticket Discovered | pass v +-----------------------------+ +| REVIEW PHASE (Fargate) | +| (same runtime monitoring) | +| | +| Input: git diff (untrusted | +| if impl phase compromised) | ++-------------+---------------+ + | + v ++-----------------------------+ +| OUTPUT GATE (post-review) | +| - Secrets scan | +| - PII scan | +| - Behavioral anomaly check | ++-------------+---------------+ + | pass + v ++-----------------------------+ | PRE-PUSH GATE | | - Final secrets scan on | | full PR diff | | - Final PII check | +| - Code safety (OWASP) | +-------------+---------------+ | pass v @@ -168,7 +199,8 @@ Jira Ticket Discovered - WebFetch interception — hook/proxy inside the agent container - Runtime monitoring — AWS-native (VPC Flow Logs, DNS logs, CloudWatch) - Output gate — in `collectPhaseOutput` before returning results -- Pre-push gate — in `pushChanges` before GitHub API calls +- Pre-push gate — in `pushFromSandbox` before the git push +- Fix-and-retry path — `fixAndRetryPush` in `poll-agent.ts` spawns a lightweight Claude agent to fix push failures. This agent receives untrusted input (the push error) and runs with `--dangerously-skip-permissions`. Its output must pass through the output gate and pre-push gate before the retry push proceeds. ## Response Model From 91cf14545186a2931fc23a170a75dc20798f15ea Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 13:47:19 +0200 Subject: [PATCH 13/71] feat: add vsc adapter plan --- .gitignore | 1 + .../plans/2026-04-09-gitlab-vcs-adapter.md | 1016 +++++++++++++++++ .../2026-04-09-gitlab-vcs-adapter-design.md | 195 ++++ 3 files changed, 1212 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-gitlab-vcs-adapter.md create mode 100644 docs/superpowers/specs/2026-04-09-gitlab-vcs-adapter-design.md diff --git a/.gitignore b/.gitignore index 9e3f888..f5d1828 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ .env*.local .workflow-data/ .env.e2e +.worktrees/ diff --git a/docs/superpowers/plans/2026-04-09-gitlab-vcs-adapter.md b/docs/superpowers/plans/2026-04-09-gitlab-vcs-adapter.md new file mode 100644 index 0000000..efc9005 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-gitlab-vcs-adapter.md @@ -0,0 +1,1016 @@ +# GitLab VCS Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `GitLabAdapter` implementing the `VCSAdapter` interface so the system can target GitLab.com repos via a `VCS_KIND=gitlab` env switch. + +**Architecture:** Direct mirror of the existing `GitHubAdapter` — new `gitlab.ts` file alongside `github.ts`, same interface, same error-handling patterns. Factory functions in `adapters.ts` and `step-adapters.ts` branch on `VCS_KIND`. Env schema updated so GitHub vars are optional when `VCS_KIND=gitlab` and vice versa. + +**Tech Stack:** `@gitbeaker/rest` (GitLab API client), Zod (env validation), Vitest (tests) + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `src/adapters/vcs/gitlab.ts` | **Create** | `GitLabAdapter` class (~250 lines) implementing all 8 `VCSAdapter` methods | +| `src/adapters/vcs/gitlab.test.ts` | **Create** | Unit tests with mocked gitbeaker (~10 test cases) | +| `env.ts` | **Modify** | Add `"gitlab"` to `VCS_KIND` enum, add `GITLAB_*` vars, make `GITHUB_*` vars optional | +| `src/lib/adapters.ts` | **Modify** | Branch VCS creation on `VCS_KIND` | +| `src/lib/step-adapters.ts` | **Modify** | Same branching as `adapters.ts` | +| `package.json` | **Modify** | Add `@gitbeaker/rest` dependency | + +No changes to `types.ts`, `workflows/agent.ts`, `sandbox/poll-agent.ts`, or any other consumer. + +--- + +## Task 1: Add `@gitbeaker/rest` dependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the dependency** + +Run: +```bash +npm install @gitbeaker/rest +``` + +- [ ] **Step 2: Verify installation** + +Run: +```bash +node -e "import('@gitbeaker/rest').then(m => console.log('OK:', Object.keys(m).slice(0,3)))" +``` +Expected: prints `OK:` followed by exported names (e.g. `Gitlab`, `Projects`, etc.) + +--- + +## Task 2: Update env schema for GitLab support + +**Files:** +- Modify: `env.ts:17-22` + +The `VCS_KIND` enum expands to `["github", "gitlab"]`. All `GITHUB_*` vars become optional (only required when `VCS_KIND=github`). New `GITLAB_*` vars are added as optional (only required when `VCS_KIND=gitlab`). Runtime validation happens in the factory — not in the schema. + +- [ ] **Step 1: Write the failing test** + +Create file `src/adapters/vcs/gitlab.test.ts` with a minimal test that imports from `env.ts` and validates the schema accepts `"gitlab"`: + +```typescript +import { describe, it, expect } from "vitest"; + +describe("GitLabAdapter env", () => { + it("VCS_KIND enum includes gitlab (compile-time check)", () => { + // This test validates that the env schema accepts "gitlab" as a VCS_KIND. + // The actual env parsing is handled by @t3-oss/env-core at startup. + // We just verify the type exists for now — the adapter import test comes in Task 3. + expect(["github", "gitlab"]).toContain("gitlab"); + }); +}); +``` + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: PASS (this is a baseline test; the real validation is compile-time) + +- [ ] **Step 2: Update the VCS_KIND enum** + +In `env.ts`, change line 18: + +```typescript +// Before: +VCS_KIND: z.enum(["github"]), +``` + +```typescript +// After: +VCS_KIND: z.enum(["github", "gitlab"]), +``` + +- [ ] **Step 3: Make GITHUB_* vars optional** + +In `env.ts`, change lines 19-22: + +```typescript +// Before: +GITHUB_TOKEN: z.string().min(1), +GITHUB_OWNER: z.string().min(1), +GITHUB_REPO: z.string().min(1), +GITHUB_BASE_BRANCH: z.string().default("main"), +``` + +```typescript +// After: +GITHUB_TOKEN: z.string().min(1).optional(), +GITHUB_OWNER: z.string().min(1).optional(), +GITHUB_REPO: z.string().min(1).optional(), +GITHUB_BASE_BRANCH: z.string().default("main"), +``` + +Note: `GITHUB_BASE_BRANCH` keeps its `.default("main")` — it's already effectively optional. + +- [ ] **Step 4: Add GITLAB_* vars** + +In `env.ts`, add after the GitHub vars block (after `GITHUB_BASE_BRANCH`): + +```typescript + // GitLab VCS + GITLAB_TOKEN: z.string().min(1).optional(), + GITLAB_PROJECT_ID: z.string().min(1).optional(), + GITLAB_BASE_BRANCH: z.string().default("main"), +``` + +- [ ] **Step 5: Run typecheck** + +Run: +```bash +npx tsc --noEmit +``` +Expected: PASS — no type errors. Existing code that accesses `env.GITHUB_TOKEN` will now get `string | undefined`, but we fix that in Task 5 (factory update). If the typecheck fails here due to those accesses, that's expected and we'll fix them in Task 5. + +--- + +## Task 3: Implement `GitLabAdapter` — `createBranch` + +**Files:** +- Create: `src/adapters/vcs/gitlab.ts` + +- [ ] **Step 1: Write the failing tests for createBranch** + +Add to `src/adapters/vcs/gitlab.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GitLabAdapter } from "./gitlab.js"; + +const mockBranches = { + create: vi.fn(), + remove: vi.fn(), + show: vi.fn(), +}; + +const mockRepositoryFiles = { + create: vi.fn(), +}; + +const mockCommits = { + create: vi.fn(), +}; + +const mockMergeRequests = { + create: vi.fn(), + all: vi.fn(), + show: vi.fn(), + allPipelines: vi.fn(), +}; + +const mockMergeRequestNotes = { + all: vi.fn(), +}; + +const mockMergeRequestDiscussions = { + all: vi.fn(), +}; + +const mockJobs = { + all: vi.fn(), + showLog: vi.fn(), +}; + +vi.mock("@gitbeaker/rest", () => ({ + Gitlab: vi.fn(() => ({ + Branches: mockBranches, + RepositoryFiles: mockRepositoryFiles, + Commits: mockCommits, + MergeRequests: mockMergeRequests, + MergeRequestNotes: mockMergeRequestNotes, + MergeRequestDiscussions: mockMergeRequestDiscussions, + Jobs: mockJobs, + })), +})); + +function glAdapter() { + return new GitLabAdapter({ + token: "glpat-xxxxxxxxxxxx", + projectId: "blazity/demo-app", + baseBranch: "main", + }); +} + +describe("GitLabAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createBranch", () => { + it("creates branch from base ref", async () => { + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockBranches.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "main", + ); + }); + + it("seeds empty repo on 404 then creates branch", async () => { + const error = new Error("404 Branch Not Found") as any; + error.cause = { response: { status: 404 } }; + mockBranches.create.mockRejectedValueOnce(error); + mockRepositoryFiles.create.mockResolvedValueOnce({ + branch: "main", + }); + // Second create call succeeds after seeding + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockRepositoryFiles.create).toHaveBeenCalledWith( + "blazity/demo-app", + "README.md", + "main", + "Initial commit", + "# Repository\n", + ); + expect(mockBranches.create).toHaveBeenCalledTimes(2); + }); + + it("force-resets existing branch by deleting and recreating on 400", async () => { + const error = new Error("Branch already exists") as any; + error.cause = { response: { status: 400 } }; + mockBranches.create.mockRejectedValueOnce(error); + mockBranches.remove.mockResolvedValueOnce({}); + // Recreate succeeds + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockBranches.remove).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + ); + expect(mockBranches.create).toHaveBeenCalledTimes(2); + }); + }); +}); +``` + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: FAIL — `./gitlab.js` does not exist + +- [ ] **Step 2: Create `gitlab.ts` with config and createBranch** + +Create `src/adapters/vcs/gitlab.ts`: + +```typescript +import { Gitlab } from "@gitbeaker/rest"; +import { FatalError } from "workflow"; +import type { + VCSAdapter, + PullRequest, + PRComment, + CheckRunResult, +} from "./types.js"; + +export interface GitLabConfig { + token: string; + projectId: string; + baseBranch: string; +} + +export class GitLabAdapter implements VCSAdapter { + private gl: InstanceType; + private projectId: string; + private baseBranch: string; + + constructor(private config: GitLabConfig) { + this.gl = new Gitlab({ token: config.token }); + this.projectId = config.projectId; + this.baseBranch = config.baseBranch; + } + + async createBranch(name: string, base: string): Promise { + try { + await this.gl.Branches.create(this.projectId, name, base); + } catch (err: any) { + const status = this.getStatusCode(err); + + if (status === 404) { + // Empty repo — seed with a README, then retry + await this.seedEmptyRepo(base); + await this.gl.Branches.create(this.projectId, name, base); + return; + } + + if (status === 400) { + // Branch already exists — delete and recreate + await this.gl.Branches.remove(this.projectId, name); + await this.gl.Branches.create(this.projectId, name, base); + return; + } + + throw err; + } + } + + private async seedEmptyRepo(branch: string): Promise { + try { + await this.gl.RepositoryFiles.create( + this.projectId, + "README.md", + branch, + "Initial commit", + "# Repository\n", + ); + } catch (err: any) { + throw new Error( + `Failed to seed empty repository ${this.projectId}: ${err.message}`, + ); + } + } + + private getStatusCode(err: any): number | undefined { + return err?.cause?.response?.status ?? err?.status ?? err?.statusCode; + } + + // Stub methods — implemented in subsequent tasks + async createPR( + _branch: string, + _title: string, + _body: string, + ): Promise { + throw new Error("Not implemented"); + } + + async push( + _branch: string, + _files: Array<{ path: string; content: string }>, + _options?: { mergeParentSha?: string }, + ): Promise { + throw new Error("Not implemented"); + } + + async getBranchSha(_branch: string): Promise { + throw new Error("Not implemented"); + } + + async getPRComments(_prId: number): Promise { + throw new Error("Not implemented"); + } + + async getCheckRunResults(_prId: number): Promise { + throw new Error("Not implemented"); + } + + async getPRConflictStatus(_prId: number): Promise { + throw new Error("Not implemented"); + } + + async findPR(_branch: string): Promise { + throw new Error("Not implemented"); + } +} +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: PASS — all 3 createBranch tests pass + +--- + +## Task 4: Implement `createPR`, `push`, `getBranchSha`, `findPR` + +**Files:** +- Modify: `src/adapters/vcs/gitlab.ts` +- Modify: `src/adapters/vcs/gitlab.test.ts` + +- [ ] **Step 1: Write failing tests for createPR, push, getBranchSha, findPR** + +Add these test blocks inside the `describe("GitLabAdapter", ...)` block in `gitlab.test.ts`: + +```typescript + describe("createPR", () => { + it("creates a merge request", async () => { + mockMergeRequests.create.mockResolvedValueOnce({ + iid: 42, + web_url: "https://gitlab.com/blazity/demo-app/-/merge_requests/42", + }); + + const adapter = glAdapter(); + const pr = await adapter.createPR("feat/test", "Add feature", "Description"); + + expect(pr.id).toBe(42); + expect(pr.url).toContain("/merge_requests/42"); + expect(pr.branch).toBe("feat/test"); + expect(mockMergeRequests.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "main", + "Add feature", + { description: "Description" }, + ); + }); + + it("throws FatalError on 409", async () => { + const error = new Error("MR already exists") as any; + error.cause = { response: { status: 409 } }; + mockMergeRequests.create.mockRejectedValueOnce(error); + + const adapter = glAdapter(); + await expect( + adapter.createPR("feat/test", "Title", "Body"), + ).rejects.toThrow("MR already exists"); + }); + + it("throws FatalError on 404", async () => { + const error = new Error("Project not found") as any; + error.cause = { response: { status: 404 } }; + mockMergeRequests.create.mockRejectedValueOnce(error); + + const adapter = glAdapter(); + await expect( + adapter.createPR("feat/test", "Title", "Body"), + ).rejects.toThrow("Project not found"); + }); + }); + + describe("push", () => { + it("creates a commit with file actions", async () => { + mockCommits.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.push("feat/test", [ + { path: "src/index.ts", content: "console.log('hello');" }, + { path: "src/utils.ts", content: "export const add = (a: number, b: number) => a + b;" }, + ]); + + expect(mockCommits.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "feat: agent implementation", + [ + { action: "update", filePath: "src/index.ts", content: "console.log('hello');" }, + { action: "update", filePath: "src/utils.ts", content: "export const add = (a: number, b: number) => a + b;" }, + ], + ); + }); + }); + + describe("getBranchSha", () => { + it("returns the commit SHA of a branch", async () => { + mockBranches.show.mockResolvedValueOnce({ + commit: { id: "abc123def456" }, + }); + + const adapter = glAdapter(); + const sha = await adapter.getBranchSha("feat/test"); + + expect(sha).toBe("abc123def456"); + expect(mockBranches.show).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + ); + }); + }); + + describe("findPR", () => { + it("returns null when no MR exists", async () => { + mockMergeRequests.all.mockResolvedValueOnce([]); + + const adapter = glAdapter(); + const pr = await adapter.findPR("feat/test"); + expect(pr).toBeNull(); + }); + + it("returns MR when one exists", async () => { + mockMergeRequests.all.mockResolvedValueOnce([ + { + iid: 42, + web_url: "https://gitlab.com/blazity/demo-app/-/merge_requests/42", + source_branch: "feat/test", + }, + ]); + + const adapter = glAdapter(); + const pr = await adapter.findPR("feat/test"); + expect(pr).not.toBeNull(); + expect(pr!.id).toBe(42); + expect(pr!.branch).toBe("feat/test"); + }); + }); +``` + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: FAIL — "Not implemented" errors from stub methods + +- [ ] **Step 2: Implement createPR** + +In `gitlab.ts`, replace the `createPR` stub with: + +```typescript + async createPR( + branch: string, + title: string, + body: string, + ): Promise { + try { + const mr = await this.gl.MergeRequests.create( + this.projectId, + branch, + this.baseBranch, + title, + { description: body }, + ); + return { id: mr.iid, url: mr.web_url, branch }; + } catch (err: any) { + const status = this.getStatusCode(err); + if (status === 409 || status === 404) { + throw new FatalError(err.message); + } + throw err; + } + } +``` + +- [ ] **Step 3: Implement push** + +In `gitlab.ts`, replace the `push` stub with: + +```typescript + async push( + branch: string, + files: Array<{ path: string; content: string }>, + _options?: { mergeParentSha?: string }, + ): Promise { + const actions = files.map((f) => ({ + action: "update" as const, + filePath: f.path, + content: f.content, + })); + + await this.gl.Commits.create( + this.projectId, + branch, + "feat: agent implementation", + actions, + ); + } +``` + +Note: `mergeParentSha` is intentionally ignored per the spec — GitLab's Commits API doesn't support multi-parent commits. The workflow's conflict resolution flow handles this by recreating the branch from base when conflicts are detected. + +- [ ] **Step 4: Implement getBranchSha** + +In `gitlab.ts`, replace the `getBranchSha` stub with: + +```typescript + async getBranchSha(branch: string): Promise { + const data = await this.gl.Branches.show(this.projectId, branch); + return data.commit.id; + } +``` + +- [ ] **Step 5: Implement findPR** + +In `gitlab.ts`, replace the `findPR` stub with: + +```typescript + async findPR(branch: string): Promise { + const mrs = await this.gl.MergeRequests.all({ + projectId: this.projectId, + sourceBranch: branch, + state: "opened", + }); + if (mrs.length === 0) return null; + const mr = mrs[0]; + return { id: mr.iid, url: mr.web_url, branch: mr.source_branch }; + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: PASS — all tests pass including new ones + +--- + +## Task 5: Implement `getPRComments`, `getCheckRunResults`, `getPRConflictStatus` + +**Files:** +- Modify: `src/adapters/vcs/gitlab.ts` +- Modify: `src/adapters/vcs/gitlab.test.ts` + +- [ ] **Step 1: Write failing tests** + +Add these test blocks inside `describe("GitLabAdapter", ...)` in `gitlab.test.ts`: + +```typescript + describe("getPRComments", () => { + it("combines discussion notes and general notes", async () => { + mockMergeRequestDiscussions.all.mockResolvedValueOnce([ + { + notes: [ + { + author: { username: "reviewer1" }, + body: "Inline comment on line 10", + system: false, + type: "DiffNote", + position: { new_path: "src/index.ts", new_line: 10 }, + }, + ], + }, + ]); + mockMergeRequestNotes.all.mockResolvedValueOnce([ + { + author: { username: "reviewer2" }, + body: "General comment", + system: false, + type: null, + }, + ]); + + const adapter = glAdapter(); + const comments = await adapter.getPRComments(42); + + expect(comments).toHaveLength(2); + expect(comments[0]).toEqual({ + author: "reviewer1", + body: "Inline comment on line 10", + liked: false, + filePath: "src/index.ts", + startLine: 10, + endLine: 10, + }); + expect(comments[1]).toEqual({ + author: "reviewer2", + body: "General comment", + liked: false, + }); + }); + }); + + describe("getCheckRunResults", () => { + it("maps GitLab CI job statuses to CheckRunResult", async () => { + mockMergeRequests.show.mockResolvedValueOnce({ sha: "head-sha-123" }); + mockMergeRequests.allPipelines.mockResolvedValueOnce([ + { id: 100, status: "failed" }, + ]); + mockJobs.all.mockResolvedValueOnce([ + { id: 1, name: "lint", status: "success" }, + { id: 2, name: "test", status: "failed" }, + { id: 3, name: "build", status: "running" }, + ]); + mockJobs.showLog.mockResolvedValueOnce("Error: test failed on line 42"); + + const adapter = glAdapter(); + const results = await adapter.getCheckRunResults(42); + + expect(results).toHaveLength(3); + expect(results[0]).toEqual({ + name: "lint", + status: "completed", + conclusion: "success", + }); + expect(results[1]).toEqual({ + name: "test", + status: "completed", + conclusion: "failure", + logs: "Error: test failed on line 42", + }); + expect(results[2]).toEqual({ + name: "build", + status: "in_progress", + conclusion: null, + }); + }); + }); + + describe("getPRConflictStatus", () => { + it("returns true when MR has conflicts", async () => { + mockMergeRequests.show.mockResolvedValueOnce({ has_conflicts: true }); + + const adapter = glAdapter(); + const hasConflicts = await adapter.getPRConflictStatus(42); + expect(hasConflicts).toBe(true); + }); + + it("returns false when MR has no conflicts", async () => { + mockMergeRequests.show.mockResolvedValueOnce({ has_conflicts: false }); + + const adapter = glAdapter(); + const hasConflicts = await adapter.getPRConflictStatus(42); + expect(hasConflicts).toBe(false); + }); + }); +``` + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: FAIL — "Not implemented" errors from stub methods + +- [ ] **Step 2: Implement getPRComments** + +In `gitlab.ts`, replace the `getPRComments` stub with: + +```typescript + async getPRComments(prId: number): Promise { + const comments: PRComment[] = []; + + // Fetch inline/diff comments from discussions + const discussions = await this.gl.MergeRequestDiscussions.all( + this.projectId, + prId, + ); + for (const discussion of discussions) { + for (const note of discussion.notes ?? []) { + if (note.system) continue; + if (note.type !== "DiffNote") continue; + comments.push({ + author: note.author?.username ?? "unknown", + body: note.body ?? "", + liked: false, + filePath: note.position?.new_path, + startLine: note.position?.new_line, + endLine: note.position?.new_line, + }); + } + } + + // Fetch general (non-inline, non-system) notes + const notes = await this.gl.MergeRequestNotes.all(this.projectId, prId); + for (const note of notes) { + if (note.system) continue; + if (note.type === "DiffNote") continue; // already captured above + comments.push({ + author: note.author?.username ?? "unknown", + body: note.body ?? "", + liked: false, + }); + } + + return comments; + } +``` + +- [ ] **Step 3: Implement getCheckRunResults** + +In `gitlab.ts`, replace the `getCheckRunResults` stub with: + +```typescript + async getCheckRunResults(prId: number): Promise { + const mr = await this.gl.MergeRequests.show(this.projectId, prId); + const pipelines = await this.gl.MergeRequests.allPipelines( + this.projectId, + prId, + ); + + if (pipelines.length === 0) return []; + + // Use the most recent pipeline + const latestPipeline = pipelines[0]; + const jobs = await this.gl.Jobs.all(this.projectId, latestPipeline.id); + + const results: CheckRunResult[] = []; + for (const job of jobs) { + const mapped = this.mapJobStatus(job.status); + const entry: CheckRunResult = { + name: job.name, + status: mapped.status, + conclusion: mapped.conclusion, + }; + + // Fetch logs for failed jobs + if ( + mapped.status === "completed" && + mapped.conclusion !== "success" && + mapped.conclusion !== null && + mapped.conclusion !== "skipped" && + mapped.conclusion !== "cancelled" + ) { + try { + const log = await this.gl.Jobs.showLog(this.projectId, job.id); + entry.logs = String(log); + } catch { + // Log fetching is best-effort + } + } + + results.push(entry); + } + + return results; + } + + private mapJobStatus( + status: string, + ): Pick { + switch (status) { + case "success": + return { status: "completed", conclusion: "success" }; + case "failed": + return { status: "completed", conclusion: "failure" }; + case "running": + return { status: "in_progress", conclusion: null }; + case "pending": + case "created": + return { status: "queued", conclusion: null }; + case "canceled": + return { status: "completed", conclusion: "cancelled" }; + case "skipped": + return { status: "completed", conclusion: "skipped" }; + default: + return { status: "queued", conclusion: null }; + } + } +``` + +- [ ] **Step 4: Implement getPRConflictStatus** + +In `gitlab.ts`, replace the `getPRConflictStatus` stub with: + +```typescript + async getPRConflictStatus(prId: number): Promise { + const mr = await this.gl.MergeRequests.show(this.projectId, prId); + return mr.has_conflicts === true; + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +```bash +npx vitest run src/adapters/vcs/gitlab.test.ts +``` +Expected: PASS — all tests pass + +--- + +## Task 6: Update factory functions in adapters.ts and step-adapters.ts + +**Files:** +- Modify: `src/lib/adapters.ts:1-42` +- Modify: `src/lib/step-adapters.ts:1-42` + +- [ ] **Step 1: Update `adapters.ts`** + +Add the import at the top of `src/lib/adapters.ts`: + +```typescript +import { GitLabAdapter } from "../adapters/vcs/gitlab.js"; +``` + +Then extract VCS creation into a helper and use it. Replace the `vcs:` line in `createAdapters()`: + +```typescript +function createVCS(): VCSAdapter { + if (env.VCS_KIND === "gitlab") { + return new GitLabAdapter({ + token: env.GITLAB_TOKEN!, + projectId: env.GITLAB_PROJECT_ID!, + baseBranch: env.GITLAB_BASE_BRANCH ?? "main", + }); + } + return new GitHubAdapter({ + token: env.GITHUB_TOKEN!, + owner: env.GITHUB_OWNER!, + repo: env.GITHUB_REPO!, + baseBranch: env.GITHUB_BASE_BRANCH ?? "main", + }); +} +``` + +And update the `createAdapters` return to use it: + +```typescript +export function createAdapters(): Adapters { + return { + issueTracker: new JiraAdapter({ + baseUrl: env.JIRA_BASE_URL, + email: env.JIRA_EMAIL, + apiToken: env.JIRA_API_TOKEN, + projectKey: env.JIRA_PROJECT_KEY, + }), + vcs: createVCS(), + messaging: new ChatSDKAdapter({ + slackToken: env.CHAT_SDK_SLACK_TOKEN, + channelId: env.CHAT_SDK_CHANNEL_ID, + botName: env.CHAT_SDK_BOT_NAME, + }), + runRegistry: new UpstashRunRegistry({ + url: env.AI_WORKFLOW_KV_REST_API_URL, + token: env.AI_WORKFLOW_KV_REST_API_TOKEN, + }), + }; +} +``` + +- [ ] **Step 2: Update `step-adapters.ts`** + +Apply the identical change to `src/lib/step-adapters.ts`: + +Add the import: +```typescript +import { GitLabAdapter } from "../adapters/vcs/gitlab.js"; +``` + +Add the same `createVCS()` helper (duplicate is fine — these files are independent entry points): + +```typescript +function createVCS(): VCSAdapter { + if (env.VCS_KIND === "gitlab") { + return new GitLabAdapter({ + token: env.GITLAB_TOKEN!, + projectId: env.GITLAB_PROJECT_ID!, + baseBranch: env.GITLAB_BASE_BRANCH ?? "main", + }); + } + return new GitHubAdapter({ + token: env.GITHUB_TOKEN!, + owner: env.GITHUB_OWNER!, + repo: env.GITHUB_REPO!, + baseBranch: env.GITHUB_BASE_BRANCH ?? "main", + }); +} +``` + +Update `createStepAdapters()` to use `vcs: createVCS()`. + +- [ ] **Step 3: Run typecheck** + +Run: +```bash +npx tsc --noEmit +``` +Expected: PASS — no type errors. The `!` non-null assertions match the pattern described in the spec (runtime validation via assertion, not Zod refine). + +- [ ] **Step 4: Run all unit tests** + +Run: +```bash +npx vitest run +``` +Expected: PASS — all existing tests plus new GitLab tests pass + +--- + +## Task 7: Final verification + +**Files:** (none — read-only checks) + +- [ ] **Step 1: Run full test suite** + +Run: +```bash +npx vitest run +``` +Expected: All tests PASS + +- [ ] **Step 2: Run typecheck** + +Run: +```bash +npx tsc --noEmit +``` +Expected: PASS + +- [ ] **Step 3: Verify file inventory matches spec** + +Confirm these files were created/modified: + +| File | Expected | +|------|----------| +| `src/adapters/vcs/gitlab.ts` | New — ~250 lines | +| `src/adapters/vcs/gitlab.test.ts` | New — ~10 test cases | +| `env.ts` | Modified — `VCS_KIND` enum, `GITLAB_*` vars, `GITHUB_*` optional | +| `src/lib/adapters.ts` | Modified — `createVCS()` helper | +| `src/lib/step-adapters.ts` | Modified — `createVCS()` helper | +| `package.json` | Modified — `@gitbeaker/rest` added | + +Run: +```bash +git diff --stat main +``` diff --git a/docs/superpowers/specs/2026-04-09-gitlab-vcs-adapter-design.md b/docs/superpowers/specs/2026-04-09-gitlab-vcs-adapter-design.md new file mode 100644 index 0000000..0410a04 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-gitlab-vcs-adapter-design.md @@ -0,0 +1,195 @@ +# GitLab VCS Adapter Design + +**Date:** 2026-04-09 +**Approach:** Direct Mirror (Approach A) + +## Goal + +Add a `GitLabAdapter` that implements the existing `VCSAdapter` interface, supporting all 8 methods currently provided by `GitHubAdapter`. The adapter targets GitLab.com only, uses `@gitbeaker/rest` as the API client, and fetches CI logs from GitLab CI/CD pipelines. + +## Decisions + +| Question | Decision | +|----------|----------| +| GitLab.com vs self-hosted | GitLab.com only | +| API client | `@gitbeaker/rest` | +| CI log source | GitLab CI pipelines only (no external commit statuses) | +| Architecture | Direct mirror — new file alongside `github.ts`, factory switch on `VCS_KIND` | + +## Files Changed + +| File | Change | +|------|--------| +| `src/adapters/vcs/gitlab.ts` | **New** — `GitLabAdapter` class (~250 lines) | +| `src/adapters/vcs/gitlab.test.ts` | **New** — unit tests with mocked gitbeaker (~150 lines) | +| `env.ts` | Add `"gitlab"` to `VCS_KIND` enum, add `GITLAB_*` env vars | +| `src/lib/adapters.ts` | Conditional VCS adapter creation based on `VCS_KIND` | +| `src/lib/step-adapters.ts` | Same conditional as `adapters.ts` | +| `package.json` | Add `@gitbeaker/rest` dependency | + +No changes to `types.ts`, `workflows/agent.ts`, `sandbox/poll-agent.ts`, or any other consumer — the `VCSAdapter` interface is unchanged. + +## GitLabAdapter Configuration + +```typescript +export interface GitLabConfig { + token: string; // GitLab personal access token (glpat-...) + projectId: string; // "owner/repo" path or numeric project ID + baseBranch: string; // Target branch for MRs (default: "main") +} +``` + +### Environment Variables + +``` +VCS_KIND=gitlab +GITLAB_TOKEN=glpat-xxxxxxxxxxxx +GITLAB_PROJECT_ID=blazity/demo-app +GITLAB_BASE_BRANCH=main +``` + +All `GITLAB_*` vars are optional at the schema level (only required when `VCS_KIND=gitlab`). All `GITHUB_*` vars become optional too (only required when `VCS_KIND=github`). + +## API Mapping + +Each `VCSAdapter` method maps to GitLab REST API equivalents via `@gitbeaker/rest`: + +### `createBranch(name, base)` + +| Step | GitHub (`@octokit/rest`) | GitLab (`@gitbeaker/rest`) | +|------|--------------------------|---------------------------| +| Get base ref | `git.getRef(heads/{base})` | Not needed — GitLab accepts branch name directly | +| Create branch | `git.createRef(refs/heads/{name}, sha)` | `Branches.create(projectId, name, base)` | +| Handle empty repo (409) | Seed README via `repos.createOrUpdateFileContents` | Seed README via `RepositoryFiles.create` | +| Handle existing branch (422/400) | `git.updateRef(force: true)` | `Branches.remove` + `Branches.create` | + +### `createPR(branch, title, body)` + +| Step | GitHub | GitLab | +|------|--------|--------| +| Create | `pulls.create(head, base, title, body)` | `MergeRequests.create(projectId, source, target, title, {description})` | +| Return value | `{id: data.number, url: data.html_url}` | `{id: mr.iid, url: mr.web_url}` | +| Fatal errors | 422, 404 | 409, 404 | + +**Note:** GitLab uses `iid` (project-scoped ID) not `id` (global ID). The `iid` is the MR number visible in the UI (e.g., `!42`), analogous to GitHub's PR number. + +### `push(branch, files, options?)` + +| Step | GitHub | GitLab | +|------|--------|--------| +| Push files | `getRef` → `getCommit` → `createBlob` (per file) → `createTree` → `createCommit` → `updateRef` | `Commits.create(projectId, branch, message, actions)` | + +GitLab's Commits API is significantly simpler — a single call replaces 5-6 GitHub API calls. Each file becomes an action: + +```typescript +const actions = files.map(f => ({ + action: "update" as const, // or "create" for new files + filePath: f.path, + content: f.content, +})); +``` + +**Merge commit handling:** When `mergeParentSha` is provided, the GitHub adapter creates a two-parent commit. GitLab's Commits API does not support multi-parent commits directly. Instead, we use the MergeRequests rebase API or handle conflict resolution at the MR level. For the initial implementation, we skip the merge-parent optimization and use a regular commit — the workflow's conflict resolution flow already recreates the branch from base when conflicts are detected. + +### `getBranchSha(branch)` + +| GitHub | GitLab | +|--------|--------| +| `git.getRef(heads/{branch})` → `data.object.sha` | `Branches.show(projectId, branch)` → `commit.id` | + +### `getPRComments(prId)` + +| Comment type | GitHub | GitLab | +|-------------|--------|--------| +| Review/inline comments | `pulls.listReviewComments` | `MergeRequestDiscussions.all` (filter for diff notes) | +| General comments | `issues.listComments` | `MergeRequestNotes.all` (filter for non-system notes) | +| Liked detection | `reactions.total_count > 0` | Note has award emoji (or simplified: skip, default `false`) | + +GitLab distinguishes between "notes" (general comments) and "discussions" (threaded diff comments). Both map to `PRComment[]`. + +For inline comments, GitLab notes include `position.new_path` and `position.new_line` which map to `filePath` and `startLine`/`endLine`. + +### `getCheckRunResults(prId)` + +| Step | GitHub | GitLab | +|------|--------|--------| +| Get head SHA | `pulls.get` → `head.sha` | `MergeRequests.show` → `sha` | +| List CI results | `checks.listForRef(sha)` | `MergeRequests.allPipelines` → `Jobs.all(pipelineId)` | +| Fetch failed logs | `actions.downloadJobLogsForWorkflowRun` | `Jobs.showLog(projectId, jobId)` | + +**Status mapping:** + +| GitLab job status | `CheckRunResult.status` | `CheckRunResult.conclusion` | +|-------------------|------------------------|-----------------------------| +| `success` | `"completed"` | `"success"` | +| `failed` | `"completed"` | `"failure"` | +| `running` | `"in_progress"` | `null` | +| `pending`, `created` | `"queued"` | `null` | +| `canceled` | `"completed"` | `"cancelled"` | +| `skipped` | `"completed"` | `"skipped"` | + +### `getPRConflictStatus(prId)` + +| GitHub | GitLab | +|--------|--------| +| `pulls.get` → `mergeable === false` | `MergeRequests.show` → `has_conflicts === true` | + +### `findPR(branch)` + +| GitHub | GitLab | +|--------|--------| +| `pulls.list({head: "owner:branch", state: "open"})` | `MergeRequests.all({projectId, sourceBranch: branch, state: "opened"})` | + +## Error Handling + +Follow the same pattern as `GitHubAdapter`: + +- **Fatal (non-retryable):** 404 (project not found), 409 (MR already exists for this branch pair). Throw `FatalError`. +- **Transient (retryable):** 401 (token expired), 403 (rate limit), 429 (too many requests), 5xx. Let the error propagate for workflow retry. +- **Branch conflicts:** 400 on branch create → delete + recreate. + +## Testing Strategy + +Mirror `github.test.ts` structure: + +1. Mock `@gitbeaker/rest` with `vi.mock` +2. Test all 8 methods with happy path +3. Test error handling: empty repo seed (createBranch), existing branch reset, fatal errors on createPR +4. Test status mapping for CI jobs + +Target: ~8-10 test cases matching the GitHub adapter's coverage plus GitLab-specific edge cases (status mapping). + +## Factory Update + +Both `adapters.ts` and `step-adapters.ts` get a `createVCS()` helper: + +```typescript +function createVCS(): VCSAdapter { + if (env.VCS_KIND === "gitlab") { + return new GitLabAdapter({ + token: env.GITLAB_TOKEN!, + projectId: env.GITLAB_PROJECT_ID!, + baseBranch: env.GITLAB_BASE_BRANCH ?? "main", + }); + } + return new GitHubAdapter({ + token: env.GITHUB_TOKEN!, + owner: env.GITHUB_OWNER!, + repo: env.GITHUB_REPO!, + baseBranch: env.GITHUB_BASE_BRANCH ?? "main", + }); +} +``` + +## Env Validation + +The `env.ts` schema makes all provider-specific vars optional. Runtime validation happens in the factory — if `VCS_KIND=gitlab` but `GITLAB_TOKEN` is missing, the `!` assertion will throw at startup. This matches how the project handles other conditional adapters. + +A future improvement could add Zod `.refine()` for cross-field validation, but that's out of scope. + +## Out of Scope + +- Self-hosted GitLab support (configurable base URL) +- GitLab-specific features beyond VCSAdapter (e.g., GitLab-specific CI features) +- Merge commit with multiple parents via Commits API (use branch reset flow instead) +- Award emoji counting for `liked` field (default to `false` initially) From 632db45390c760f553d1df87851f7b05321b5cc9 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 14:09:26 +0200 Subject: [PATCH 14/71] fix: cc oauth --- src/sandbox/manager.test.ts | 34 +++++++++++++++++++++++++++++++--- src/sandbox/manager.ts | 29 ++++++++++++++++++++++++----- src/sandbox/poll-agent.ts | 2 +- src/sandbox/wrapper-script.ts | 3 +++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index 6240ac5..97bf1a2 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -62,7 +62,7 @@ describe("SandboxManager", () => { expect(sandbox.sandboxId).toBe("sbx-test-123"); }); - it("does not write wrapper script or requirements during provision", async () => { + it("writes agent-env.sh with auth credentials during provision", async () => { const manager = new SandboxManager({ githubToken: "ghp_test", owner: "test-org", @@ -76,8 +76,36 @@ describe("SandboxManager", () => { await manager.provision("feat/test-branch"); - // writeFiles should not be called during provision (no wrapper script or requirements) - expect(mockWriteFiles).not.toHaveBeenCalled(); + // writeFiles should be called once — to persist auth env vars to /tmp/agent-env.sh + expect(mockWriteFiles).toHaveBeenCalledTimes(1); + const [[files]] = mockWriteFiles.mock.calls; + expect(files).toHaveLength(1); + expect(files[0].path).toBe("/tmp/agent-env.sh"); + const content = Buffer.from(files[0].content).toString(); + expect(content).toContain("ANTHROPIC_API_KEY"); + expect(content).toContain("sk-ant-test"); + expect(content).toContain("CLAUDE_MODEL"); + }); + + it("writes CLAUDE_CODE_OAUTH_TOKEN when OAuth token is provided", async () => { + const manager = new SandboxManager({ + githubToken: "ghp_test", + owner: "test-org", + repo: "test-repo", + claudeCodeOauthToken: "oauth-token-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + await manager.provision("feat/test-branch"); + + const [[files]] = mockWriteFiles.mock.calls; + const content = Buffer.from(files[0].content).toString(); + expect(content).toContain("CLAUDE_CODE_OAUTH_TOKEN"); + expect(content).toContain("oauth-token-test"); + expect(content).not.toContain("ANTHROPIC_API_KEY"); }); it("configures stop hook when enabled", async () => { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index ae174c7..45b6a37 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -148,13 +148,26 @@ export class SandboxManager { // Install Claude Code await sandbox.runCommand("npm", ["install", "-g", "@anthropic-ai/claude-code"]); - // Skip interactive onboarding (required for headless OAuth token auth) + // Write auth env vars to a file that phase scripts can source. + // Sandbox.create({ env }) does NOT propagate vars to runCommand sessions, + // so we persist them to disk and source before every `claude` invocation. + const envLines: string[] = []; if (this.config.claudeCodeOauthToken) { - await sandbox.runCommand("bash", [ - "-c", - `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, - ]); + envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${this.shellQuote(this.config.claudeCodeOauthToken)}`); + } else if (this.config.anthropicApiKey) { + envLines.push(`export ANTHROPIC_API_KEY=${this.shellQuote(this.config.anthropicApiKey)}`); } + envLines.push(`export CLAUDE_MODEL=${this.shellQuote(this.config.claudeModel)}`); + + await sandbox.writeFiles([ + { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, + ]); + + // Skip interactive onboarding (required for headless auth — both OAuth and API key) + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, + ]); // Install skills globally (outside the client repo) await this.installGlobalSkills(sandbox); @@ -174,6 +187,12 @@ export class SandboxManager { } } + /** Safely quote a value for use in a shell variable assignment. */ + private shellQuote(val: string): string { + // Single-quote the value, escaping any embedded single quotes. + return `'${val.replace(/'/g, "'\\''")}'`; + } + async configureStopHook(sandbox: SandboxInstance, enabled: boolean): Promise { await configureStopHookInSandbox(sandbox, enabled); } diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 958a686..d37d0fd 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -84,7 +84,7 @@ export async function fixAndRetryPush( await sandbox.runCommand("bash", [ "-c", - `cat /tmp/fix-prompt.txt | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, + `[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh; cat /tmp/fix-prompt.txt | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, ]); // Log fix agent output for observability diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts index ed3441a..2486f42 100644 --- a/src/sandbox/wrapper-script.ts +++ b/src/sandbox/wrapper-script.ts @@ -27,6 +27,9 @@ export function buildPhaseScript(opts: PhaseScriptOptions): string { # --- Cleanup stale files from prior runs --- rm -f ${sentinelFile} ${outputFile} ${stderrFile} +# --- Source auth env vars (Sandbox.create env does not propagate to runCommand) --- +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + # --- Phase: ${opts.phase} --- cat ${inputFile} | claude \\ ${claudeFlags} \\ From c130dfdee8143b4d01bb478554f9e79de111a9c6 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 14:11:09 +0200 Subject: [PATCH 15/71] feat: remove redundant model export --- src/sandbox/manager.test.ts | 2 +- src/sandbox/manager.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index 97bf1a2..981ce88 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -84,7 +84,7 @@ describe("SandboxManager", () => { const content = Buffer.from(files[0].content).toString(); expect(content).toContain("ANTHROPIC_API_KEY"); expect(content).toContain("sk-ant-test"); - expect(content).toContain("CLAUDE_MODEL"); + expect(content).not.toContain("CLAUDE_MODEL"); }); it("writes CLAUDE_CODE_OAUTH_TOKEN when OAuth token is provided", async () => { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 45b6a37..f9a6c5d 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -151,17 +151,19 @@ export class SandboxManager { // Write auth env vars to a file that phase scripts can source. // Sandbox.create({ env }) does NOT propagate vars to runCommand sessions, // so we persist them to disk and source before every `claude` invocation. + // NOTE: Only auth credentials go here. CLAUDE_MODEL is passed via the + // explicit --model flag in phase scripts and poll-agent to keep one source of truth. const envLines: string[] = []; if (this.config.claudeCodeOauthToken) { envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${this.shellQuote(this.config.claudeCodeOauthToken)}`); } else if (this.config.anthropicApiKey) { envLines.push(`export ANTHROPIC_API_KEY=${this.shellQuote(this.config.anthropicApiKey)}`); } - envLines.push(`export CLAUDE_MODEL=${this.shellQuote(this.config.claudeModel)}`); await sandbox.writeFiles([ { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, ]); + await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); // Skip interactive onboarding (required for headless auth — both OAuth and API key) await sandbox.runCommand("bash", [ From 41a2418221ea17e77c339eb729fa5d179001b48b Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 14:53:08 +0200 Subject: [PATCH 16/71] feat: add webhooks --- env.ts | 3 + src/routes/webhooks/jira.post.ts | 108 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/routes/webhooks/jira.post.ts diff --git a/env.ts b/env.ts index 9255357..c981377 100644 --- a/env.ts +++ b/env.ts @@ -48,6 +48,9 @@ export const env = createEnv({ // Cron CRON_SECRET: z.string().min(1).optional(), + // Jira Webhook + JIRA_WEBHOOK_SECRET: z.string().min(1).optional(), + // Redis (run registry) AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts new file mode 100644 index 0000000..be22871 --- /dev/null +++ b/src/routes/webhooks/jira.post.ts @@ -0,0 +1,108 @@ +import { defineEventHandler, readBody, getHeader, createError } from "h3"; +import { env } from "../../../env.js"; +import { createAdapters } from "../../lib/adapters.js"; +import { dispatchTicket } from "../../lib/dispatch.js"; +import { logger } from "../../lib/logger.js"; + +/** + * Jira webhook handler — triggers the same dispatch logic as the cron poller. + * + * Configure in Jira (Settings → System → Webhooks) with: + * URL: https:///webhooks/jira + * Headers: Authorization: Bearer + * Events: Issue updated + * + * The webhook fires immediately when a ticket is moved to the AI column, + * eliminating the up-to-1-minute polling delay. + */ +export default defineEventHandler(async (event) => { + verifyWebhookAuth(event); + + const body = await readBody(event); + + const ticketKey = extractTicketKey(body); + if (!ticketKey) { + logger.debug({ webhookEvent: body?.webhookEvent }, "webhook_ignored_no_ticket_key"); + return { status: "ignored", reason: "no_ticket_key" }; + } + + const projectKey = extractProjectKey(body); + if (projectKey && projectKey.toUpperCase() !== env.JIRA_PROJECT_KEY.toUpperCase()) { + logger.debug( + { ticketKey, projectKey, expectedProject: env.JIRA_PROJECT_KEY }, + "webhook_ignored_wrong_project", + ); + return { status: "ignored", reason: "wrong_project", ticketKey }; + } + + const targetColumn = extractDestinationStatus(body); + if (!targetColumn || targetColumn.toLowerCase() !== env.COLUMN_AI.toLowerCase()) { + logger.debug( + { ticketKey, targetColumn, expectedColumn: env.COLUMN_AI }, + "webhook_ignored_not_ai_column", + ); + return { status: "ignored", reason: "not_ai_column", ticketKey }; + } + + logger.info({ ticketKey }, "webhook_received_ai_column_transition"); + + const adapters = createAdapters(); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + + logger.info( + { ticketKey, started: result.started, reason: result.reason, runId: result.runId }, + "webhook_dispatch_result", + ); + + return { + status: result.started ? "dispatched" : "skipped", + ticketKey, + reason: result.reason, + }; +}); + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +function verifyWebhookAuth(event: Parameters[0]): void { + if (!env.JIRA_WEBHOOK_SECRET) return; + + const headerSecret = getHeader(event, "authorization")?.replace(/^Bearer\s+/i, ""); + if (headerSecret === env.JIRA_WEBHOOK_SECRET) return; + + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); +} + +// --------------------------------------------------------------------------- +// Payload parsing — Jira Cloud webhook payloads +// --------------------------------------------------------------------------- + +/** + * Extract the issue key from a Jira webhook payload. + * Jira sends `issue.key` (e.g. "AWT-42") in most webhook event types. + */ +function extractTicketKey(body: any): string | null { + return body?.issue?.key ?? null; +} + +/** + * Extract the project key from a Jira webhook payload. + * Available at `issue.fields.project.key` (e.g. "AWT"). + */ +function extractProjectKey(body: any): string | null { + return body?.issue?.fields?.project?.key ?? null; +} + +/** + * Extract the destination status after a transition. + * + * Jira "issue_updated" webhooks include a `changelog.items` array. + * When the status field changes, one item will have `field === "status"` + * with `toString` being the new status name. + */ +function extractDestinationStatus(body: any): string | null { + const items: any[] = body?.changelog?.items ?? []; + const statusChange = items.find((item: any) => item.field === "status"); + return statusChange?.toString ?? null; +} From ec41be5a0dbe322ab2b28e8e244636d5d8110218 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 15:06:50 +0200 Subject: [PATCH 17/71] feat: add better error handling --- env.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/env.ts b/env.ts index c981377..9db67cf 100644 --- a/env.ts +++ b/env.ts @@ -2,6 +2,12 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; export const env = createEnv({ + onValidationError: (issues) => { + const details = (issues as Array<{ path?: (string | number)[]; message: string }>) + .map((i) => ` ${i.path?.join(".") ?? "unknown"}: ${i.message}`) + .join("\n"); + throw new Error(`Invalid environment variables:\n${details}`); + }, server: { // Issue Tracker ISSUE_TRACKER_KIND: z.enum(["jira"]), From 396aaf622d6d6d293453ebf065ecaba973001508 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 15:12:40 +0200 Subject: [PATCH 18/71] fix: webhook token --- src/routes/webhooks/jira.post.ts | 57 ++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index be22871..55b7a2a 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -1,4 +1,5 @@ -import { defineEventHandler, readBody, getHeader, createError } from "h3"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { defineEventHandler, readRawBody, getHeader, createError } from "h3"; import { env } from "../../../env.js"; import { createAdapters } from "../../lib/adapters.js"; import { dispatchTicket } from "../../lib/dispatch.js"; @@ -8,17 +9,25 @@ import { logger } from "../../lib/logger.js"; * Jira webhook handler — triggers the same dispatch logic as the cron poller. * * Configure in Jira (Settings → System → Webhooks) with: - * URL: https:///webhooks/jira - * Headers: Authorization: Bearer - * Events: Issue updated + * URL: https:///webhooks/jira + * Secret: + * Events: Issue updated + * + * Jira signs the payload with HMAC-SHA256 and sends it in the + * X-Hub-Signature header (format: "sha256="). * * The webhook fires immediately when a ticket is moved to the AI column, * eliminating the up-to-1-minute polling delay. */ export default defineEventHandler(async (event) => { - verifyWebhookAuth(event); + const rawBody = await readRawBody(event, "utf8"); + + verifyWebhookSignature( + rawBody ?? "", + getHeader(event, "x-hub-signature"), + ); - const body = await readBody(event); + const body = rawBody ? JSON.parse(rawBody) : {}; const ticketKey = extractTicketKey(body); if (!ticketKey) { @@ -62,16 +71,42 @@ export default defineEventHandler(async (event) => { }); // --------------------------------------------------------------------------- -// Auth +// Auth — HMAC-SHA256 signature verification // --------------------------------------------------------------------------- -function verifyWebhookAuth(event: Parameters[0]): void { +/** + * Verify the X-Hub-Signature header sent by Jira Cloud. + * + * Jira computes HMAC-SHA256 of the raw request body using the webhook + * secret and sends it as "sha256=" in the X-Hub-Signature header. + * + * When JIRA_WEBHOOK_SECRET is not set, verification is skipped (open access). + */ +function verifyWebhookSignature( + rawBody: string, + signatureHeader: string | undefined, +): void { if (!env.JIRA_WEBHOOK_SECRET) return; - const headerSecret = getHeader(event, "authorization")?.replace(/^Bearer\s+/i, ""); - if (headerSecret === env.JIRA_WEBHOOK_SECRET) return; + if (!signatureHeader) { + throw createError({ statusCode: 401, statusMessage: "Missing X-Hub-Signature header" }); + } + + const [method, receivedSig] = signatureHeader.split("=", 2); + if (!method || !receivedSig) { + throw createError({ statusCode: 401, statusMessage: "Malformed X-Hub-Signature header" }); + } - throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + const expectedSig = createHmac(method, env.JIRA_WEBHOOK_SECRET) + .update(rawBody, "utf8") + .digest("hex"); + + const a = Buffer.from(receivedSig, "hex"); + const b = Buffer.from(expectedSig, "hex"); + + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw createError({ statusCode: 401, statusMessage: "Invalid webhook signature" }); + } } // --------------------------------------------------------------------------- From d342ca52cb1d3a4b630666c0ea773222dd4dffc1 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 15:41:48 +0200 Subject: [PATCH 19/71] chore: trigger redeploy From ed707662f80c4cad364fd1f089f7e1241c397966 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 15:47:58 +0200 Subject: [PATCH 20/71] wip --- src/routes/webhooks/jira.post.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 55b7a2a..6f4715b 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -86,14 +86,25 @@ function verifyWebhookSignature( rawBody: string, signatureHeader: string | undefined, ): void { + console.log("[webhook-auth] JIRA_WEBHOOK_SECRET set:", !!env.JIRA_WEBHOOK_SECRET); + console.log("[webhook-auth] JIRA_WEBHOOK_SECRET value:", env.JIRA_WEBHOOK_SECRET); + console.log("[webhook-auth] X-Hub-Signature header:", signatureHeader); + console.log("[webhook-auth] rawBody length:", rawBody.length); + console.log("[webhook-auth] rawBody first 200 chars:", rawBody.slice(0, 200)); + if (!env.JIRA_WEBHOOK_SECRET) return; if (!signatureHeader) { + console.log("[webhook-auth] REJECTED: no X-Hub-Signature header"); throw createError({ statusCode: 401, statusMessage: "Missing X-Hub-Signature header" }); } const [method, receivedSig] = signatureHeader.split("=", 2); + console.log("[webhook-auth] method:", method); + console.log("[webhook-auth] receivedSig:", receivedSig); + if (!method || !receivedSig) { + console.log("[webhook-auth] REJECTED: malformed header"); throw createError({ statusCode: 401, statusMessage: "Malformed X-Hub-Signature header" }); } @@ -101,12 +112,19 @@ function verifyWebhookSignature( .update(rawBody, "utf8") .digest("hex"); + console.log("[webhook-auth] expectedSig:", expectedSig); + console.log("[webhook-auth] receivedSig:", receivedSig); + console.log("[webhook-auth] match:", expectedSig === receivedSig); + const a = Buffer.from(receivedSig, "hex"); const b = Buffer.from(expectedSig, "hex"); if (a.length !== b.length || !timingSafeEqual(a, b)) { + console.log("[webhook-auth] REJECTED: signature mismatch", { aLen: a.length, bLen: b.length }); throw createError({ statusCode: 401, statusMessage: "Invalid webhook signature" }); } + + console.log("[webhook-auth] PASSED"); } // --------------------------------------------------------------------------- From 7d5a9383979a733dc46276bc6ff3eec27d52063c Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 9 Apr 2026 15:50:05 +0200 Subject: [PATCH 21/71] wip --- src/routes/webhooks/jira.post.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 6f4715b..8a6bf06 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -20,6 +20,8 @@ import { logger } from "../../lib/logger.js"; * eliminating the up-to-1-minute polling delay. */ export default defineEventHandler(async (event) => { + console.log("[webhook] all headers:", JSON.stringify(event.node.req.headers, null, 2)); + const rawBody = await readRawBody(event, "utf8"); verifyWebhookSignature( From 83146f23b7edf5ffb7db59c9bbbcfe720229337a Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 10 Apr 2026 07:31:37 +0200 Subject: [PATCH 22/71] feat: remove logs --- src/routes/webhooks/jira.post.ts | 52 +++++++++----------------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 8a6bf06..2798408 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -13,21 +13,17 @@ import { logger } from "../../lib/logger.js"; * Secret: * Events: Issue updated * - * Jira signs the payload with HMAC-SHA256 and sends it in the - * X-Hub-Signature header (format: "sha256="). + * Auth strategy (checked in order): + * 1. X-Hub-Signature HMAC-SHA256 (Jira signs the body when a secret is set) + * 2. ?secret= query param (fallback — some Jira instances don't send the header) * * The webhook fires immediately when a ticket is moved to the AI column, * eliminating the up-to-1-minute polling delay. */ export default defineEventHandler(async (event) => { - console.log("[webhook] all headers:", JSON.stringify(event.node.req.headers, null, 2)); + const rawBody = (await readRawBody(event, "utf8")) ?? ""; - const rawBody = await readRawBody(event, "utf8"); - - verifyWebhookSignature( - rawBody ?? "", - getHeader(event, "x-hub-signature"), - ); + verifyWebhookAuth(event, rawBody); const body = rawBody ? JSON.parse(rawBody) : {}; @@ -73,60 +69,42 @@ export default defineEventHandler(async (event) => { }); // --------------------------------------------------------------------------- -// Auth — HMAC-SHA256 signature verification +// Auth // --------------------------------------------------------------------------- /** - * Verify the X-Hub-Signature header sent by Jira Cloud. - * - * Jira computes HMAC-SHA256 of the raw request body using the webhook - * secret and sends it as "sha256=" in the X-Hub-Signature header. - * - * When JIRA_WEBHOOK_SECRET is not set, verification is skipped (open access). + * Verify the X-Hub-Signature HMAC sent by Jira Cloud. */ -function verifyWebhookSignature( +function verifyWebhookAuth( + event: Parameters[0], rawBody: string, - signatureHeader: string | undefined, ): void { - console.log("[webhook-auth] JIRA_WEBHOOK_SECRET set:", !!env.JIRA_WEBHOOK_SECRET); - console.log("[webhook-auth] JIRA_WEBHOOK_SECRET value:", env.JIRA_WEBHOOK_SECRET); - console.log("[webhook-auth] X-Hub-Signature header:", signatureHeader); - console.log("[webhook-auth] rawBody length:", rawBody.length); - console.log("[webhook-auth] rawBody first 200 chars:", rawBody.slice(0, 200)); - if (!env.JIRA_WEBHOOK_SECRET) return; + const signatureHeader = getHeader(event, "x-hub-signature"); if (!signatureHeader) { - console.log("[webhook-auth] REJECTED: no X-Hub-Signature header"); throw createError({ statusCode: 401, statusMessage: "Missing X-Hub-Signature header" }); } - const [method, receivedSig] = signatureHeader.split("=", 2); - console.log("[webhook-auth] method:", method); - console.log("[webhook-auth] receivedSig:", receivedSig); + verifyHmacSignature(rawBody, signatureHeader); +} +function verifyHmacSignature(rawBody: string, signatureHeader: string): void { + const [method, receivedSig] = signatureHeader.split("=", 2); if (!method || !receivedSig) { - console.log("[webhook-auth] REJECTED: malformed header"); throw createError({ statusCode: 401, statusMessage: "Malformed X-Hub-Signature header" }); } - const expectedSig = createHmac(method, env.JIRA_WEBHOOK_SECRET) + const expectedSig = createHmac(method, env.JIRA_WEBHOOK_SECRET!) .update(rawBody, "utf8") .digest("hex"); - console.log("[webhook-auth] expectedSig:", expectedSig); - console.log("[webhook-auth] receivedSig:", receivedSig); - console.log("[webhook-auth] match:", expectedSig === receivedSig); - const a = Buffer.from(receivedSig, "hex"); const b = Buffer.from(expectedSig, "hex"); if (a.length !== b.length || !timingSafeEqual(a, b)) { - console.log("[webhook-auth] REJECTED: signature mismatch", { aLen: a.length, bLen: b.length }); throw createError({ statusCode: 401, statusMessage: "Invalid webhook signature" }); } - - console.log("[webhook-auth] PASSED"); } // --------------------------------------------------------------------------- From a8facccda6365526ed90a6755b23139b6d60c490 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 10 Apr 2026 07:37:00 +0200 Subject: [PATCH 23/71] feat: remove desination column logic --- src/routes/webhooks/jira.post.ts | 29 +++-------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 2798408..be92fe0 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -13,11 +13,9 @@ import { logger } from "../../lib/logger.js"; * Secret: * Events: Issue updated * - * Auth strategy (checked in order): - * 1. X-Hub-Signature HMAC-SHA256 (Jira signs the body when a secret is set) - * 2. ?secret= query param (fallback — some Jira instances don't send the header) + * Auth: X-Hub-Signature HMAC-SHA256 (Jira signs the body when a secret is set) * - * The webhook fires immediately when a ticket is moved to the AI column, + * The webhook fires immediately when a ticket changes, * eliminating the up-to-1-minute polling delay. */ export default defineEventHandler(async (event) => { @@ -42,16 +40,7 @@ export default defineEventHandler(async (event) => { return { status: "ignored", reason: "wrong_project", ticketKey }; } - const targetColumn = extractDestinationStatus(body); - if (!targetColumn || targetColumn.toLowerCase() !== env.COLUMN_AI.toLowerCase()) { - logger.debug( - { ticketKey, targetColumn, expectedColumn: env.COLUMN_AI }, - "webhook_ignored_not_ai_column", - ); - return { status: "ignored", reason: "not_ai_column", ticketKey }; - } - - logger.info({ ticketKey }, "webhook_received_ai_column_transition"); + logger.info({ ticketKey }, "webhook_received"); const adapters = createAdapters(); const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); @@ -127,15 +116,3 @@ function extractProjectKey(body: any): string | null { return body?.issue?.fields?.project?.key ?? null; } -/** - * Extract the destination status after a transition. - * - * Jira "issue_updated" webhooks include a `changelog.items` array. - * When the status field changes, one item will have `field === "status"` - * with `toString` being the new status name. - */ -function extractDestinationStatus(body: any): string | null { - const items: any[] = body?.changelog?.items ?? []; - const statusChange = items.find((item: any) => item.field === "status"); - return statusChange?.toString ?? null; -} From 9a77c51a8e9b876438d85b3b0db25b3235220259 Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:35:54 +0200 Subject: [PATCH 24/71] feat: add gitlab (#49) * feat: add gitlab * chore: regenerate pnpm lock * feat: review improvements --- .env.example | 11 +- env.test.ts | 73 +- env.ts | 72 +- package-lock.json | 14064 ++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 49 + src/adapters/vcs/github.ts | 10 +- src/adapters/vcs/gitlab.test.ts | 401 + src/adapters/vcs/gitlab.ts | 323 + src/adapters/vcs/types.ts | 2 +- src/lib/adapters.ts | 9 +- src/lib/create-vcs.ts | 27 + src/lib/step-adapters.ts | 9 +- src/sandbox/manager.test.ts | 35 +- src/sandbox/manager.ts | 51 +- src/sandbox/poll-agent.test.ts | 75 +- src/sandbox/poll-agent.ts | 23 +- src/workflows/agent.ts | 31 +- 18 files changed, 15196 insertions(+), 70 deletions(-) create mode 100644 package-lock.json create mode 100644 src/adapters/vcs/gitlab.test.ts create mode 100644 src/adapters/vcs/gitlab.ts create mode 100644 src/lib/create-vcs.ts diff --git a/.env.example b/.env.example index 7d9b2ed..818f941 100644 --- a/.env.example +++ b/.env.example @@ -9,13 +9,22 @@ COLUMN_AI=AI COLUMN_AI_REVIEW=AI Review COLUMN_BACKLOG=Backlog -# VCS (GitHub) +# VCS — choose one provider by setting VCS_KIND to "github" or "gitlab". +# Only ONE VCS_KIND line should be active in this file. VCS_KIND=github + +# --- GitHub (active when VCS_KIND=github) --- GITHUB_TOKEN=ghp_xxxxxxxxxxxx GITHUB_OWNER=your-org GITHUB_REPO=your-repo GITHUB_BASE_BRANCH=main +# --- GitLab (set VCS_KIND=gitlab above to switch, then fill these in) --- +# GITLAB_TOKEN=glpat-xxxxxxxxxxxx +# GITLAB_PROJECT_ID=your-group/your-repo +# GITLAB_BASE_BRANCH=main +# GITLAB_HOST=https://gitlab.com # override for self-hosted GitLab + # Messaging (Chat SDK) CHAT_SDK_SLACK_TOKEN=xoxb-xxxxxxxxxxxx CHAT_SDK_CHANNEL_ID=C0123456789 diff --git a/env.test.ts b/env.test.ts index d55381c..4522535 100644 --- a/env.test.ts +++ b/env.test.ts @@ -59,10 +59,81 @@ describe("env", () => { it("throws on missing required field", async () => { const partial = { ...VALID_ENV }; - delete (partial as any).GITHUB_TOKEN; + delete (partial as any).JIRA_API_TOKEN; Object.assign(process.env, partial); await expect(async () => { await import("./env.js"); }).rejects.toThrow(); }); + + it("parses valid GitLab env", async () => { + const gitlabEnv = { ...VALID_ENV }; + gitlabEnv.VCS_KIND = "gitlab"; + delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_OWNER; + delete (gitlabEnv as any).GITHUB_REPO; + delete (gitlabEnv as any).GITHUB_BASE_BRANCH; + (gitlabEnv as any).GITLAB_TOKEN = "glpat-test"; + (gitlabEnv as any).GITLAB_PROJECT_ID = "group/repo"; + (gitlabEnv as any).GITLAB_BASE_BRANCH = "develop"; + Object.assign(process.env, gitlabEnv); + + const { getVcsConfig } = await import("./env.js"); + const vcs = getVcsConfig(); + expect(vcs.kind).toBe("gitlab"); + expect(vcs.token).toBe("glpat-test"); + expect(vcs.repoPath).toBe("group/repo"); + expect(vcs.baseBranch).toBe("develop"); + expect(vcs.host).toBe("https://gitlab.com"); + }); + + it("honors GITLAB_HOST for self-hosted instances", async () => { + const gitlabEnv = { ...VALID_ENV }; + gitlabEnv.VCS_KIND = "gitlab"; + delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_OWNER; + delete (gitlabEnv as any).GITHUB_REPO; + (gitlabEnv as any).GITLAB_TOKEN = "glpat-test"; + (gitlabEnv as any).GITLAB_PROJECT_ID = "group/repo"; + (gitlabEnv as any).GITLAB_HOST = "https://gitlab.example.com"; + Object.assign(process.env, gitlabEnv); + + const { getVcsConfig } = await import("./env.js"); + expect(getVcsConfig().host).toBe("https://gitlab.example.com"); + }); + + it("throws at startup when VCS_KIND=gitlab but GitLab vars missing", async () => { + const gitlabEnv = { ...VALID_ENV }; + gitlabEnv.VCS_KIND = "gitlab"; + delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_OWNER; + delete (gitlabEnv as any).GITHUB_REPO; + Object.assign(process.env, gitlabEnv); + + // Fail-fast: module import itself must throw before any workflow runs. + await expect(async () => { + await import("./env.js"); + }).rejects.toThrow("VCS_KIND=gitlab requires GITLAB_TOKEN and GITLAB_PROJECT_ID"); + }); + + it("throws at startup when VCS_KIND=github but GitHub vars missing", async () => { + const partial = { ...VALID_ENV }; + delete (partial as any).GITHUB_TOKEN; + Object.assign(process.env, partial); + + await expect(async () => { + await import("./env.js"); + }).rejects.toThrow("VCS_KIND=github requires GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO"); + }); + + it("getVcsConfig returns GitHub config", async () => { + Object.assign(process.env, VALID_ENV); + const { getVcsConfig } = await import("./env.js"); + const vcs = getVcsConfig(); + expect(vcs.kind).toBe("github"); + expect(vcs.token).toBe("ghp_test"); + expect(vcs.repoPath).toBe("test-org/test-repo"); + expect(vcs.baseBranch).toBe("main"); + expect(vcs.host).toBe("https://github.com"); + }); }); diff --git a/env.ts b/env.ts index 9db67cf..e5a3273 100644 --- a/env.ts +++ b/env.ts @@ -21,12 +21,19 @@ export const env = createEnv({ COLUMN_BACKLOG: z.string().min(1), // VCS - VCS_KIND: z.enum(["github"]), - GITHUB_TOKEN: z.string().min(1), - GITHUB_OWNER: z.string().min(1), - GITHUB_REPO: z.string().min(1), + VCS_KIND: z.enum(["github", "gitlab"]), + GITHUB_TOKEN: z.string().min(1).optional(), + GITHUB_OWNER: z.string().min(1).optional(), + GITHUB_REPO: z.string().min(1).optional(), GITHUB_BASE_BRANCH: z.string().default("main"), + // GitLab VCS + GITLAB_TOKEN: z.string().min(1).optional(), + GITLAB_PROJECT_ID: z.string().min(1).optional(), + GITLAB_BASE_BRANCH: z.string().default("main"), + /** Base URL for self-hosted GitLab. Defaults to https://gitlab.com. */ + GITLAB_HOST: z.string().url().default("https://gitlab.com"), + // Messaging CHAT_SDK_SLACK_TOKEN: z.string().min(1), CHAT_SDK_CHANNEL_ID: z.string().min(1), @@ -65,4 +72,61 @@ export const env = createEnv({ emptyStringAsUndefined: true, }); +// Cross-field validation — fail fast at startup instead of at first workflow +// step. createEnv() validates each field individually; provider-specific +// credentials are intentionally optional at the schema level (only one VCS is +// active at a time) so we enforce the conditional requirement here. +{ + if (env.VCS_KIND === "gitlab") { + if (!env.GITLAB_TOKEN || !env.GITLAB_PROJECT_ID) { + throw new Error( + "Invalid environment variables:\n" + + " VCS_KIND=gitlab requires GITLAB_TOKEN and GITLAB_PROJECT_ID", + ); + } + } else if (env.VCS_KIND === "github") { + if (!env.GITHUB_TOKEN || !env.GITHUB_OWNER || !env.GITHUB_REPO) { + throw new Error( + "Invalid environment variables:\n" + + " VCS_KIND=github requires GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO", + ); + } + } +} + export type Env = typeof env; + +export interface VcsConfig { + kind: "github" | "gitlab"; + token: string; + repoPath: string; + baseBranch: string; + /** Base URL for the VCS host (e.g. https://gitlab.example.com or https://github.com). */ + host: string; +} + +/** Resolve VCS credentials from env. Throws if required vars are missing for the active VCS_KIND. */ +export function getVcsConfig(): VcsConfig { + if (env.VCS_KIND === "gitlab") { + if (!env.GITLAB_TOKEN || !env.GITLAB_PROJECT_ID) { + throw new Error("GITLAB_TOKEN and GITLAB_PROJECT_ID are required when VCS_KIND=gitlab"); + } + return { + kind: "gitlab", + token: env.GITLAB_TOKEN, + repoPath: env.GITLAB_PROJECT_ID, + baseBranch: env.GITLAB_BASE_BRANCH ?? "main", + host: env.GITLAB_HOST, + }; + } + if (!env.GITHUB_TOKEN || !env.GITHUB_OWNER || !env.GITHUB_REPO) { + throw new Error("GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO are required when VCS_KIND=github"); + } + return { + kind: "github", + token: env.GITHUB_TOKEN, + repoPath: `${env.GITHUB_OWNER}/${env.GITHUB_REPO}`, + baseBranch: env.GITHUB_BASE_BRANCH ?? "main", + host: "https://github.com", + }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1864488 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,14064 @@ +{ + "name": "ai-workflow", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-workflow", + "dependencies": { + "@chat-adapter/slack": "^4.20.2", + "@gitbeaker/rest": "^43.8.0", + "@octokit/rest": "^22.0.1", + "@t3-oss/env-core": "^0.13.10", + "@upstash/redis": "^1.37.0", + "@vercel/sandbox": "^1.8.1", + "chat": "^4.20.2", + "h3": "^1", + "nitropack": "^2", + "pino": "^10.3.1", + "workflow": "latest", + "zod": "^3.25.76" + }, + "devDependencies": { + "@workflow/vitest": "latest", + "@workflow/world-postgres": "latest", + "typescript": "^5.8", + "vitest": "^3" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", + "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/xml-builder": "^3.972.17", + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/signature-v4": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz", + "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.15", + "@aws-sdk/nested-clients": "^3.996.3", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", + "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", + "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", + "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", + "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-retry": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", + "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/middleware-host-header": "^3.972.9", + "@aws-sdk/middleware-logger": "^3.972.9", + "@aws-sdk/middleware-recursion-detection": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/region-config-resolver": "^3.972.11", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@aws-sdk/util-user-agent-browser": "^3.972.9", + "@aws-sdk/util-user-agent-node": "^3.973.15", + "@smithy/config-resolver": "^4.4.14", + "@smithy/core": "^3.23.14", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/hash-node": "^4.2.13", + "@smithy/invalid-dependency": "^4.2.13", + "@smithy/middleware-content-length": "^4.2.13", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-retry": "^4.5.0", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.45", + "@smithy/util-defaults-mode-node": "^4.2.49", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", + "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/config-resolver": "^4.4.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", + "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", + "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-endpoints": "^3.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", + "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", + "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/types": "^3.973.7", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", + "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@chat-adapter/shared": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/shared/-/shared-4.24.0.tgz", + "integrity": "sha512-TINx2tGIb7R76LWRII7LUclRFGUAB4ytosEaL054bYm0T1t52suQAHSqCZrLjlc060TNhBNUFJY3Fd9YpTantw==", + "license": "MIT", + "dependencies": { + "chat": "4.24.0" + } + }, + "node_modules/@chat-adapter/slack": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/slack/-/slack-4.24.0.tgz", + "integrity": "sha512-K8QOYfYMVV8yQixspLAilhh2nou2sybW/M5+8WunegZZlpLqLfQHl78fAJsp+CRveo24bR4UlCcT92/EpGkwOA==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.24.0", + "@slack/web-api": "^7.14.0", + "chat": "4.24.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gitbeaker/core": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-43.8.0.tgz", + "integrity": "sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==", + "license": "MIT", + "dependencies": { + "@gitbeaker/requester-utils": "^43.8.0", + "qs": "^6.14.0", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/requester-utils": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-43.8.0.tgz", + "integrity": "sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==", + "license": "MIT", + "dependencies": { + "picomatch-browser": "^2.2.6", + "qs": "^6.14.0", + "rate-limiter-flexible": "^8.0.1", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/rest": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-43.8.0.tgz", + "integrity": "sha512-xxqsNsUXaFang9b2e/NTIgqUeuUlifA2Opy1mOVqTDuJZZNIOTgUNyziwBJoleBhMC0XuvY3JNVMWthufcVjRw==", + "license": "MIT", + "dependencies": { + "@gitbeaker/core": "^43.8.0", + "@gitbeaker/requester-utils": "^43.8.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@graphile/logger": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz", + "integrity": "sha512-jjcWBokl9eb1gVJ85QmoaQ73CQ52xAaOCF29ukRbYNl6lY+ts0ErTaDYOBlejcbUs2OpaiqYLO5uDhyLFzWw4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT", + "peer": true + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.18.tgz", + "integrity": "sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz", + "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nuxt/kit": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", + "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", + "license": "MIT", + "dependencies": { + "c12": "^3.3.3", + "consola": "^3.4.2", + "defu": "^6.1.4", + "destr": "^2.0.5", + "errx": "^0.1.0", + "exsolve": "^1.0.8", + "ignore": "^7.0.5", + "jiti": "^2.6.1", + "klona": "^2.0.6", + "mlly": "^1.8.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "rc9": "^3.0.0", + "scule": "^1.3.0", + "semver": "^7.7.4", + "tinyglobby": "^0.2.15", + "ufo": "^1.6.3", + "unctx": "^2.5.0", + "untyped": "^2.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@nuxt/kit/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@oclif/core": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.8.1.tgz", + "integrity": "sha512-07mq0vKCWNsB85ZHeBMlTAiO0KLFqHyAeRK3bD2K8CI1tX3tiwkWw1lZQZkiw8MUBrhxdROhMkYMY4Q0l7JHqA==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.1", + "semver": "^7.7.3", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.14", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "6.2.37", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.37.tgz", + "integrity": "sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-wasm": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-wasm/-/watcher-wasm-2.5.6.tgz", + "integrity": "sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==", + "bundleDependencies": [ + "napi-wasm" + ], + "license": "MIT", + "dependencies": { + "is-glob": "^4.0.3", + "napi-wasm": "^1.1.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.7.0.tgz", + "integrity": "sha512-0UTYalzk2t6S4rA2uHOz5bSSW2CHdv4vggJI6Alg90yvl0UgXs6XSXpH96OH+bRkX4J/06djv29pqXJ0lq5Kag==", + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-alias": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", + "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "rollup": ">=4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT", + "peer": true + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.1.tgz", + "integrity": "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.0.tgz", + "integrity": "sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", + "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.14", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", + "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", + "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", + "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", + "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", + "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", + "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.29", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", + "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-middleware": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", + "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.1", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", + "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", + "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", + "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", + "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", + "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", + "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", + "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", + "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", + "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", + "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", + "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", + "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", + "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", + "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.45", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", + "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", + "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.14", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", + "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", + "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", + "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.22", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", + "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@swc/cli": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.8.1.tgz", + "integrity": "sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@xhmikosr/bin-wrapper": "^14.0.0", + "commander": "^8.3.0", + "minimatch": "^9.0.3", + "piscina": "^4.3.1", + "semver": "^7.3.8", + "slash": "3.0.0", + "source-map": "^0.7.3", + "tinyglobby": "^0.2.13" + }, + "bin": { + "spack": "bin/spack.js", + "swc": "bin/swc.js", + "swcx": "bin/swcx.js" + }, + "engines": { + "node": ">= 20.19.0" + }, + "peerDependencies": { + "@swc/core": "^1.2.66", + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@swc/cli/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "peer": true + }, + "node_modules/@swc/cli/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@swc/cli/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@swc/cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@swc/cli/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@swc/core": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz", + "integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.3", + "@swc/core-darwin-x64": "1.15.3", + "@swc/core-linux-arm-gnueabihf": "1.15.3", + "@swc/core-linux-arm64-gnu": "1.15.3", + "@swc/core-linux-arm64-musl": "1.15.3", + "@swc/core-linux-x64-gnu": "1.15.3", + "@swc/core-linux-x64-musl": "1.15.3", + "@swc/core-win32-arm64-msvc": "1.15.3", + "@swc/core-win32-ia32-msvc": "1.15.3", + "@swc/core-win32-x64-msvc": "1.15.3" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz", + "integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz", + "integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz", + "integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz", + "integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz", + "integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz", + "integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz", + "integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz", + "integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz", + "integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz", + "integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@t3-oss/env-core": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.11.tgz", + "integrity": "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==", + "license": "MIT", + "peerDependencies": { + "arktype": "^2.1.0", + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/interpret": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/interpret/-/interpret-1.1.4.tgz", + "integrity": "sha512-r+tPKWHYqaxJOYA3Eik0mMi+SEREqOXLmsooRFmc6GHv7nWUDixFtKN+cegvsPlDcEZd9wxsdp041v2imQuvag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@upstash/redis": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.37.0.tgz", + "integrity": "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vercel/cli-auth": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@vercel/cli-auth/-/cli-auth-0.0.1.tgz", + "integrity": "sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==", + "dependencies": { + "async-listen": "3.0.0", + "open": "8.4.0", + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/cli-auth/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@vercel/cli-auth/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vercel/cli-auth/node_modules/open": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", + "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vercel/cli-auth/node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/functions": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-3.4.3.tgz", + "integrity": "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-web-identity": "*" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-web-identity": { + "optional": true + } + } + }, + "node_modules/@vercel/nft": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", + "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^13.0.0", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@vercel/nft/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vercel/queue": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vercel/queue/-/queue-0.1.4.tgz", + "integrity": "sha512-wo+jCycmCX078vQSbkX+RcLvySONDCK0f9aQp5UMKQD1+B+xKt3YVbIYbZukvoHQpbm5nnk6If+ADSeK/PmCgQ==", + "license": "MIT", + "dependencies": { + "@vercel/oidc": "^3.0.5", + "minimatch": "^10.2.4", + "mixpart": "0.0.5", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vercel/sandbox": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@vercel/sandbox/-/sandbox-1.9.3.tgz", + "integrity": "sha512-G6ef1izdlYftkmv2xxBfks76gm2oxIH3LgiASe8WMw7xoge6zFzyFjY91/dPMT0Pkd4UNX9nsaOedj98ywdk8Q==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.2.0", + "@workflow/serde": "4.1.0-beta.2", + "async-retry": "1.3.3", + "jsonlines": "0.1.1", + "ms": "2.1.3", + "picocolors": "^1.1.1", + "tar-stream": "3.1.7", + "undici": "^7.16.0", + "xdg-app-paths": "5.1.0", + "zod": "3.24.4" + } + }, + "node_modules/@vercel/sandbox/node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@workflow/astro": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@workflow/astro/-/astro-4.0.0.tgz", + "integrity": "sha512-yqd0ibUB3L58UZz4jgavL02ugtoqft61M4BK9sJSnl336gb3piTt8rEtUB45r0FhEyeEc4S/5PIZ9P7I6BK+bA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/rollup": "4.0.0", + "@workflow/swc-plugin": "4.1.0", + "@workflow/vite": "4.0.0", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/@workflow/astro/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/@workflow/builders": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/builders/-/builders-4.0.1.tgz", + "integrity": "sha512-IYYb6eNbZ1x+nhvHdCnYTbz0NJAYexYwoIVhm7mYqNmYddli9IkTYmD5AukXUP+ZBHMZyivwuhANYs5g+ayb7Q==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/core": "4.2.0", + "@workflow/errors": "4.1.0", + "@workflow/swc-plugin": "4.1.0", + "@workflow/utils": "4.1.0", + "builtin-modules": "5.0.0", + "chalk": "5.6.2", + "enhanced-resolve": "5.19.0", + "esbuild": "^0.27.3", + "find-up": "7.0.0", + "json5": "2.2.3", + "tinyglobby": "0.2.15" + } + }, + "node_modules/@workflow/cli": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@workflow/cli/-/cli-4.2.0.tgz", + "integrity": "sha512-ikpR55/MnEodB5k7D0A14FlQTKE96+893Kj/rSmNJBm1auBefPXkPpVO1NxWov0xowVXZmRoSdPBnLvbVknwNQ==", + "license": "Apache-2.0", + "dependencies": { + "@oclif/core": "4.8.1", + "@oclif/plugin-help": "6.2.37", + "@swc/core": "1.15.3", + "@vercel/cli-auth": "0.0.1", + "@workflow/builders": "4.0.1", + "@workflow/core": "4.2.0", + "@workflow/errors": "4.1.0", + "@workflow/swc-plugin": "4.1.0", + "@workflow/utils": "4.1.0", + "@workflow/web": "4.1.0", + "@workflow/world": "4.1.0", + "@workflow/world-local": "4.1.0", + "@workflow/world-vercel": "4.1.0", + "boxen": "8.0.1", + "builtin-modules": "5.0.0", + "chalk": "5.6.2", + "chokidar": "4.0.3", + "date-fns": "4.1.0", + "dotenv": "^17.3.1", + "easy-table": "1.2.0", + "enhanced-resolve": "5.19.0", + "esbuild": "^0.27.3", + "find-up": "7.0.0", + "mixpart": "0.0.4", + "open": "10.2.0", + "ora": "8.2.0", + "terminal-link": "5.0.0", + "tinyglobby": "0.2.15", + "xdg-app-paths": "5.1.0", + "zod": "4.3.6" + }, + "bin": { + "wf": "bin/run.js", + "workflow": "bin/run.js" + } + }, + "node_modules/@workflow/cli/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/cli/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@workflow/cli/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@workflow/cli/node_modules/mixpart": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/mixpart/-/mixpart-0.0.4.tgz", + "integrity": "sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@workflow/cli/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@workflow/cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@workflow/cli/node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@workflow/cli/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@workflow/core": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@workflow/core/-/core-4.2.0.tgz", + "integrity": "sha512-60B9bmA8Zog5KzppvDSdRDCh8kb4o0DHCDK4JamapxLt/ax3ubqvesnmTCFvltcjcqIF/kOwTmnaI1bMCtag9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-web-identity": "3.972.13", + "@jridgewell/trace-mapping": "0.3.31", + "@standard-schema/spec": "1.0.0", + "@types/ms": "2.1.0", + "@vercel/functions": "^3.4.3", + "@workflow/errors": "4.1.0", + "@workflow/serde": "4.1.0", + "@workflow/utils": "4.1.0", + "@workflow/world": "4.1.0", + "@workflow/world-local": "4.1.0", + "@workflow/world-vercel": "4.1.0", + "debug": "4.4.3", + "devalue": "5.6.3", + "ms": "2.1.3", + "nanoid": "5.1.6", + "seedrandom": "3.0.5", + "semver": "7.7.4", + "ulid": "~3.0.1", + "zod": "4.3.6" + }, + "peerDependencies": { + "@opentelemetry/api": "1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@workflow/core/node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, + "node_modules/@workflow/core/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/core/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@workflow/errors": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/errors/-/errors-4.1.0.tgz", + "integrity": "sha512-MLf82s2hFgRIGaJRI0BQvhrNoCK7mOPSNGIBom42phbnrWbj2O0YSpZTBr1dNNFF7Cd8TgLEmaQrCsHZqlWhJw==", + "license": "Apache-2.0", + "dependencies": { + "@workflow/utils": "4.1.0", + "ms": "2.1.3" + } + }, + "node_modules/@workflow/nest": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@workflow/nest/-/nest-0.0.0.tgz", + "integrity": "sha512-Ji3je93q68yF75a8k2ZKxebuSpiZBPBHftExHi6v2liv8bdbFPyBtCLitKaCmAFNUV4xZV8w/6Xe0YNmy6Iz8g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/swc-plugin": "4.1.0", + "pathe": "2.0.3" + }, + "bin": { + "workflow-nest": "dist/cli.js" + }, + "peerDependencies": { + "@nestjs/common": ">=10.0.0", + "@nestjs/core": ">=10.0.0", + "@swc/cli": ">=0.4.0", + "@swc/core": ">=1.5.0" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": false + }, + "@nestjs/core": { + "optional": false + }, + "@swc/cli": { + "optional": false + }, + "@swc/core": { + "optional": false + } + } + }, + "node_modules/@workflow/next": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/next/-/next-4.0.1.tgz", + "integrity": "sha512-e3u7Zz4QHQVmxfCoyUSXQzVUle6uwbHWdysHT3M/tnRg3k6sk5LRltePKGIcibB57DSjbSwBFk9KExtkGsV7LA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/core": "4.2.0", + "@workflow/swc-plugin": "4.1.0", + "semver": "7.7.4", + "watchpack": "2.5.1" + }, + "peerDependencies": { + "next": ">13" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + } + } + }, + "node_modules/@workflow/nitro": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/nitro/-/nitro-4.0.1.tgz", + "integrity": "sha512-fUZj8D6Htc1FmaNKqY8QdvExB8VR6pwsEBC/VrVI9cLdjPsaEvQmFvphVDdg0hkQ05sBRDJrHLJmfiqHGWun1A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/core": "4.2.0", + "@workflow/rollup": "4.0.0", + "@workflow/swc-plugin": "4.1.0", + "@workflow/vite": "4.0.0", + "exsolve": "1.0.8", + "pathe": "2.0.3" + } + }, + "node_modules/@workflow/nitro/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/@workflow/nuxt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/nuxt/-/nuxt-4.0.1.tgz", + "integrity": "sha512-fycbLWbgbTdtXmdaZYPCr6E1LtzadaAz4K8tEf1d3w0KNK2+NfIzyM0KjVByQuu5SSQ55UyFrHote8+fIzqYMw==", + "license": "Apache-2.0", + "dependencies": { + "@nuxt/kit": "4.4.2", + "@workflow/nitro": "4.0.1" + } + }, + "node_modules/@workflow/rollup": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@workflow/rollup/-/rollup-4.0.0.tgz", + "integrity": "sha512-SF57HfxSxYs8tBS2o96pQMj33swVeznz3Wb1+hpL4RKLHvb8MDUwWGedLHU9Bw5CBCHuAqU/X1P9xBkUb7sBZA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/swc-plugin": "4.1.0", + "exsolve": "1.0.7" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, + "node_modules/@workflow/sveltekit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@workflow/sveltekit/-/sveltekit-4.0.0.tgz", + "integrity": "sha512-q3ehMkJE5o2SId5CmoyR2QK3+p7Ptm1zqtyGlvTRk9U+wkb1u74GquX4cLN0C4MJ7aPw7O8+ZDK9DqbmkQr04w==", + "license": "Apache-2.0", + "dependencies": { + "@swc/core": "1.15.3", + "@workflow/builders": "4.0.1", + "@workflow/rollup": "4.0.0", + "@workflow/swc-plugin": "4.1.0", + "@workflow/vite": "4.0.0", + "exsolve": "^1.0.8", + "fs-extra": "^11.3.2", + "pathe": "^2.0.3" + } + }, + "node_modules/@workflow/sveltekit/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/@workflow/swc-plugin": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/swc-plugin/-/swc-plugin-4.1.0.tgz", + "integrity": "sha512-FcWKJwzkRqf0u08/WCyg2n1H3932JQpFk3g6edE7trXKFW06a8o4F22k/xgPFsbxAq8UpywcIN7f46H0/uH5Tw==", + "license": "Apache-2.0", + "peerDependencies": { + "@swc/core": "1.15.3" + } + }, + "node_modules/@workflow/typescript-plugin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/typescript-plugin/-/typescript-plugin-4.0.1.tgz", + "integrity": "sha512-n2CYGiywL7QeBxdYXAeKcEXBu+T3DMFoau84OO+h6ugaa6Me++UZEbEoVnG7RvsYua0jLDbmWkIPYy4GJcOMCA==", + "license": "Apache-2.0", + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/@workflow/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-wE7hTfSzR0sEougUluG6fFNR8O98F86v7nz3QGqPwtlNM1ZadW5s65Jhog9b0IA6PcAE4KHDy/iKktEyc23prw==", + "license": "Apache-2.0", + "dependencies": { + "ms": "2.1.3" + } + }, + "node_modules/@workflow/vite": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@workflow/vite/-/vite-4.0.0.tgz", + "integrity": "sha512-vhuOlTUozjDL0TcQhSkxoiAD0FsLwvpNa236W4DmPFHXbFrNadvFmPut/aLRcGubGcBAUItnBEy+Nqyi9/qZuA==", + "license": "Apache-2.0", + "dependencies": { + "@workflow/builders": "4.0.1" + } + }, + "node_modules/@workflow/vitest": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@workflow/vitest/-/vitest-4.0.1.tgz", + "integrity": "sha512-6h8E6uTwPiSLkeKtOt2oABRtZGDODgqX8dgLEzWt8UMxYcSevL9JULmIaucyFpBznQ+YKQM/+NHNIjWNYIN7CQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@workflow/builders": "4.0.1", + "@workflow/core": "4.2.0", + "@workflow/rollup": "4.0.0", + "@workflow/world": "4.1.0", + "@workflow/world-local": "4.1.0" + }, + "peerDependencies": { + "vite": ">=6.0.0", + "vitest": ">=3.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@workflow/vitest/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/vitest/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@workflow/web": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/web/-/web-4.1.0.tgz", + "integrity": "sha512-iCDzJPBG6gyxNmnQGN55T3Q0bXCFG1ZkjkVbozajR6bfH41OZvmLDLwK6dJlqP+iqu3HjBtBIpPn0p2Wo+7jhw==", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.21.0" + } + }, + "node_modules/@workflow/world-local": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world-local/-/world-local-4.1.0.tgz", + "integrity": "sha512-dOi/2M3q0kFMnY6dovgojj4s0ddrAVSxk1MNQOarf3q4XmW4Q9C9+bsA3d/EXthQmb+9eiI6PiDPxQJALcUekw==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/queue": "0.1.4", + "@workflow/errors": "4.1.0", + "@workflow/utils": "4.1.0", + "@workflow/world": "4.1.0", + "async-sema": "3.1.1", + "ulid": "~3.0.1", + "undici": "7.22.0", + "zod": "4.3.6" + }, + "peerDependencies": { + "@opentelemetry/api": "1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@workflow/world-local/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/world-local/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@workflow/world-local/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@workflow/world-postgres": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world-postgres/-/world-postgres-4.1.0.tgz", + "integrity": "sha512-Y7KjMeEiANxEQKERHeGf6Oa3PwL1HKmRe6yqDesqnlVq3WztpuN3U+vew10LlQtaaYtQLILrQEaS9VRV6r0g9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/queue": "0.1.4", + "@workflow/errors": "4.1.0", + "@workflow/utils": "4.1.0", + "@workflow/world": "4.1.0", + "@workflow/world-local": "4.1.0", + "cbor-x": "1.6.0", + "dotenv": "17.3.1", + "drizzle-orm": "0.45.1", + "graphile-worker": "0.16.6", + "pg": "8.20.0", + "ulid": "~3.0.1", + "zod": "4.3.6" + }, + "bin": { + "workflow-postgres-setup": "bin/setup.js" + } + }, + "node_modules/@workflow/world-postgres/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/world-postgres/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@workflow/world-vercel": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world-vercel/-/world-vercel-4.1.0.tgz", + "integrity": "sha512-l9Q/jHRk1v1VYLttwXMRaf/1eHOsvTVbecVEJI0lpt4zP3FH2dMyXl25TIl6k+s7a4qdXyySy62LtCx4ijy2DA==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.2.0", + "@vercel/queue": "0.1.4", + "@workflow/errors": "4.1.0", + "@workflow/world": "4.1.0", + "cbor-x": "1.6.0", + "undici": "7.22.0", + "zod": "4.3.6" + }, + "peerDependencies": { + "@opentelemetry/api": "1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@workflow/world-vercel/node_modules/@workflow/world": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/world/-/world-4.1.0.tgz", + "integrity": "sha512-lvJD7vQTjuWn/sxXUnK+oiszVuWrg4YJAccaMojBb42zbp38/V9Lsig0zuSR3pPsKrjfvmiWtGn8ATma8Kp+1Q==", + "license": "Apache-2.0", + "dependencies": { + "ulid": "~3.0.1" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@workflow/world-vercel/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@workflow/world-vercel/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@xhmikosr/archive-type": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/archive-type/-/archive-type-8.0.1.tgz", + "integrity": "sha512-toXuiWChyfOpEiCPsIw6HGHaNji5LVkvB6EREL548vGWr+hGaehwxG4LzN20vm9aGFXwnA/Jty8yW2/SmV+1zQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "^21.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/bin-check": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/bin-check/-/bin-check-8.2.1.tgz", + "integrity": "sha512-DNruLq+kalxcE7JeDxtqrN9kyWjLW8VqsQPLRTwD1t9ck/1rF4qBL0mX5Fe2/xLOMjo5wPb67BNX2kSAhzfLjA==", + "license": "MIT", + "peer": true, + "dependencies": { + "execa": "^9.6.1", + "isexe": "^4.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/bin-wrapper": { + "version": "14.2.2", + "resolved": "https://registry.npmjs.org/@xhmikosr/bin-wrapper/-/bin-wrapper-14.2.2.tgz", + "integrity": "sha512-4me/0Tw0ORrrRLliLc1w6K0unrnclVaFAp69z8fNu1rcYtD/pKtI1lZWvZ8htiRQtqhoqxBiQ2qfkZBN8q2KAw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xhmikosr/bin-check": "^8.2.1", + "@xhmikosr/downloader": "^16.1.1", + "@xhmikosr/os-filter-obj": "^4.0.0", + "binary-version-check": "^6.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/decompress": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress/-/decompress-11.1.1.tgz", + "integrity": "sha512-KdjwFbTzcpGaTIPncNaPLOHocBSF1hHo4s7gr+ZzzoB2bzVzFumzawqKTij0Vpw0idM4C2FZFPktIfyznkeJTQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xhmikosr/decompress-tar": "^9.0.1", + "@xhmikosr/decompress-tarbz2": "^9.0.1", + "@xhmikosr/decompress-targz": "^9.0.1", + "@xhmikosr/decompress-unzip": "^8.1.0", + "graceful-fs": "^4.2.11", + "strip-dirs": "^3.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/decompress-tar": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tar/-/decompress-tar-9.0.1.tgz", + "integrity": "sha512-4AkVR1SoqTxYY22IRRYKDeLirPIDGqMqYsqgjKYuwhgRcBb+yDP4t5Xph33UCzL/nahK/aADmlMEjTNstbX7kw==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "^21.3.0", + "is-stream": "^4.0.1", + "tar-stream": "3.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/decompress-tar/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@xhmikosr/decompress-tarbz2": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tarbz2/-/decompress-tarbz2-9.0.1.tgz", + "integrity": "sha512-aFONnsbqEOuXudvK7V7wB8dcEAKR389oUYQfZhrQZA8OtogJpDjrUAvEH3Qlc9yFqTU6r5/svTEcRwtXhoIJbQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xhmikosr/decompress-tar": "^9.0.0", + "file-type": "^21.3.0", + "is-stream": "^4.0.1", + "seek-bzip": "^2.0.0", + "unbzip2-stream": "^1.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/decompress-tarbz2/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@xhmikosr/decompress-targz": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-targz/-/decompress-targz-9.0.1.tgz", + "integrity": "sha512-1JXu2b6yrpm5EuBoOzMU57B4qrHXJKWQQ7LlMynNEiz85mEjDciO3ayf//GXaTLLCEKiHjWlU3q3THjgf7uODA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xhmikosr/decompress-tar": "^9.0.0", + "file-type": "^21.3.0", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/decompress-targz/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@xhmikosr/decompress-unzip": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-unzip/-/decompress-unzip-8.1.0.tgz", + "integrity": "sha512-hVcpEZIS8avXU1ioR0Pb2LcBYHfah1lzzTQPDItkBi3W+kSE/DxSeEgOoHJB8rn+Izm0ArWZxxlpsvEK4ySjaw==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "^21.3.0", + "get-stream": "^9.0.1", + "yauzl": "^3.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/downloader": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@xhmikosr/downloader/-/downloader-16.1.1.tgz", + "integrity": "sha512-1B2ZqYDpIHn9bjah48rEo33GbmoV8hufXap/3KHStgIM9R9/QDm1pajqDwEgqDORMl2eZ7Dpbz71Xi854y3m3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xhmikosr/archive-type": "^8.0.1", + "@xhmikosr/decompress": "^11.1.1", + "content-disposition": "^1.0.1", + "defaults": "^2.0.2", + "ext-name": "^5.0.0", + "file-type": "^21.3.0", + "filenamify": "^7.0.1", + "get-stream": "^9.0.1", + "got": "^14.6.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xhmikosr/os-filter-obj": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/os-filter-obj/-/os-filter-obj-4.0.0.tgz", + "integrity": "sha512-CBJYipR5lrtQQZl9ylarWyh1qhcs/tMy9ydSHte/Hefn3ev8NMvS3ss+eqiXEoBr2wBVgKj2qjcViXO9P/8K4A==", + "license": "MIT", + "peer": true, + "dependencies": { + "arch": "^3.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-listen": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", + "integrity": "sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/binary-version": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz", + "integrity": "sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw==", + "license": "MIT", + "peer": true, + "dependencies": { + "execa": "^8.0.1", + "find-versions": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version-check": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/binary-version-check/-/binary-version-check-6.1.0.tgz", + "integrity": "sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA==", + "license": "MIT", + "peer": true, + "dependencies": { + "binary-version": "^7.1.0", + "semver": "^7.6.0", + "semver-truncate": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/binary-version/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/binary-version/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-version/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", + "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/byte-counter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", + "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.18", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz", + "integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-cache-semantics": "^4.0.4", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.5.5", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.1", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chat": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.24.0.tgz", + "integrity": "sha512-0TxglwtGRMGlqERuHVZZ27Z4YBeZH3oRXCqHZYuI41L7xcSHF5C3wEHTMdVqHp3p8ZKQcKYQPOwYWvaeFVa4+g==", + "license": "MIT", + "dependencies": { + "@workflow/serde": "4.1.0-beta.2", + "mdast-util-to-string": "^4.0.0", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "remend": "^1.2.1", + "unified": "^11.0.5" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clean-stack/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/compatx": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/compatx/-/compatx-0.2.0.tgz", + "integrity": "sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/db0": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", + "integrity": "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==", + "license": "MIT", + "peerDependencies": { + "@electric-sql/pglite": "*", + "@libsql/client": "*", + "better-sqlite3": "*", + "drizzle-orm": "*", + "mysql2": "*", + "sqlite3": "*" + }, + "peerDependenciesMeta": { + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-2.0.2.tgz", + "integrity": "sha512-cuIw0PImdp76AOfgkjbW4VhQODRmNNcKR73vdCH5cLd/ifj7aamfoXvYgfGkEAjNJZ3ozMIy9Gu2LutUkGEPbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/easy-table": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.2.0.tgz", + "integrity": "sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "optionalDependencies": { + "wcwidth": "^1.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/errx": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/errx/-/errx-0.1.0.tgz", + "integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/express/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "license": "MIT" + }, + "node_modules/ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.28.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filename-reserved-regex": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-4.0.0.tgz", + "integrity": "sha512-9ZT504KxEQDamsOogZImAWGEN24R1uFAxU3ZS4AZqn2ooidmN68Olh7n4/RcA4lLatZztjA0ZSuxeLHVoCc8JA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-7.0.1.tgz", + "integrity": "sha512-9b4rfnaX2MkJCgp27wypV6DAMvj4WMOSgJ+TdcpJIO84Dql+Cv6iJjdG4XDTLubOWkfNiBv3joO59sau/TXw+Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "filename-reserved-regex": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", + "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz", + "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sindresorhus/is": "^7.0.1", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.12", + "decompress-response": "^10.0.0", + "form-data-encoder": "^4.0.2", + "http2-wrapper": "^2.2.1", + "keyv": "^5.5.3", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^4.26.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphile-config": { + "version": "0.0.1-beta.18", + "resolved": "https://registry.npmjs.org/graphile-config/-/graphile-config-0.0.1-beta.18.tgz", + "integrity": "sha512-uMdF9Rt8/NwT1wVXNleYgM5ro2hHDodHiKA3efJhgdU8iP+r/hksnghOHreMva0sF5tV73f4TpiELPUR0g7O9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/interpret": "^1.1.3", + "@types/node": "^22.16.3", + "@types/semver": "^7.7.0", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "interpret": "^3.1.1", + "semver": "^7.7.2", + "tslib": "^2.8.1", + "yargs": "^17.7.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/graphile-config/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/graphile-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/graphile-config/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graphile-config/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphile-worker": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/graphile-worker/-/graphile-worker-0.16.6.tgz", + "integrity": "sha512-e7gGYDmGqzju2l83MpzX8vNG/lOtVJiSzI3eZpAFubSxh/cxs7sRrRGBGjzBP1kNG0H+c95etPpNRNlH65PYhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphile/logger": "^0.2.0", + "@types/debug": "^4.1.10", + "@types/pg": "^8.10.5", + "cosmiconfig": "^8.3.6", + "graphile-config": "^0.0.1-beta.4", + "json5": "^2.2.3", + "pg": "^8.11.3", + "tslib": "^2.6.2", + "yargs": "^17.7.2" + }, + "bin": { + "graphile-worker": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gzip-size": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz", + "integrity": "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-shutdown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz", + "integrity": "sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/httpxy": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.0.tgz", + "integrity": "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inspect-with-kind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz", + "integrity": "sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==", + "license": "ISC", + "peer": true, + "dependencies": { + "kind-of": "^6.0.2" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonlines": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", + "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/knitwork": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.3.0.tgz", + "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listhen": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.1.tgz", + "integrity": "sha512-4EhoyVcXEpNlY5HJRSQpH7Rba94M8N2JmI62ePjl0lrJKXSfG0F1FAgHGxBoz/T3pe41sUEwkIRRIcaUL0/Ofw==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.6", + "@parcel/watcher-wasm": "^2.5.6", + "citty": "^0.2.2", + "consola": "^3.4.2", + "crossws": ">=0.2.0 <0.5.0", + "defu": "^6.1.6", + "get-port-please": "^3.2.0", + "h3": "^1.15.11", + "http-shutdown": "^1.2.2", + "jiti": "^2.6.1", + "mlly": "^1.8.2", + "node-forge": "^1.4.0", + "pathe": "^2.0.3", + "std-env": "^4.0.0", + "tinyclip": "^0.1.12", + "ufo": "^1.6.3", + "untun": "^0.1.3", + "uqr": "^0.1.2" + }, + "bin": { + "listen": "bin/listhen.mjs", + "listhen": "bin/listhen.mjs" + } + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "license": "MIT", + "peer": true, + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-asynchronous/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mixpart": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mixpart/-/mixpart-0.0.5.tgz", + "integrity": "sha512-TpWi9/2UIr7VWCVAM7NB4WR4yOglAetBkuKfxs3K0vFcUukqAaW1xsgX0v1gNGiDKzYhPHFcHgarC7jmnaOy4w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nitropack": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz", + "integrity": "sha512-C8vO7RxkU0AQ3HbYUumuG6MVM5JjRaBchke/rYFOp3EvrLtTBHZYhDVGECdpa27vNuOYRzm3GtQMn2YDOjDJLA==", + "license": "MIT", + "dependencies": { + "@cloudflare/kv-asset-handler": "^0.4.2", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.2", + "@rollup/plugin-inject": "^5.0.5", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@vercel/nft": "^1.5.0", + "archiver": "^7.0.1", + "c12": "^3.3.4", + "chokidar": "^5.0.0", + "citty": "^0.2.2", + "compatx": "^0.2.0", + "confbox": "^0.2.4", + "consola": "^3.4.2", + "cookie-es": "^2.0.1", + "croner": "^10.0.1", + "crossws": "^0.3.5", + "db0": "^0.3.4", + "defu": "^6.1.6", + "destr": "^2.0.5", + "dot-prop": "^10.1.0", + "esbuild": "^0.27.5", + "escape-string-regexp": "^5.0.0", + "etag": "^1.8.1", + "exsolve": "^1.0.8", + "globby": "^16.2.0", + "gzip-size": "^7.0.0", + "h3": "^1.15.10", + "hookable": "^5.5.3", + "httpxy": "^0.5.0", + "ioredis": "^5.10.1", + "jiti": "^2.6.1", + "klona": "^2.0.6", + "knitwork": "^1.3.0", + "listhen": "^1.9.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mime": "^4.1.0", + "mlly": "^1.8.2", + "node-fetch-native": "^1.6.7", + "node-mock-http": "^1.0.4", + "ofetch": "^1.5.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "pretty-bytes": "^7.1.0", + "radix3": "^1.1.2", + "rollup": "^4.60.1", + "rollup-plugin-visualizer": "^7.0.1", + "scule": "^1.3.0", + "semver": "^7.7.4", + "serve-placeholder": "^2.0.2", + "serve-static": "^2.2.1", + "source-map": "^0.7.6", + "std-env": "^4.0.0", + "ufo": "^1.6.3", + "ultrahtml": "^1.6.0", + "uncrypto": "^0.1.3", + "unctx": "^2.5.0", + "unenv": "2.0.0-rc.24", + "unimport": "^6.0.2", + "unplugin-utils": "^0.3.1", + "unstorage": "^1.17.5", + "untyped": "^2.0.0", + "unwasm": "^0.5.3", + "youch": "^4.1.1", + "youch-core": "^0.3.3" + }, + "bin": { + "nitro": "dist/cli/index.mjs", + "nitropack": "dist/cli/index.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "xml2js": "^0.6.2" + }, + "peerDependenciesMeta": { + "xml2js": { + "optional": true + } + } + }, + "node_modules/nitropack/node_modules/cookie-es": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", + "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", + "license": "MIT" + }, + "node_modules/nitropack/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/p-cancelable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event/node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT", + "peer": true + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/picomatch-browser": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/picomatch-browser/-/picomatch-browser-2.2.6.tgz", + "integrity": "sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/piscina": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.2.tgz", + "integrity": "sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==", + "license": "MIT", + "peer": true, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.1" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-bytes": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz", + "integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limiter-flexible": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-8.3.0.tgz", + "integrity": "sha512-mzwlfipDLlRinPgELqVDJetke6Snq26nL565m8nLWXIcWgosYSeNRgqwh7ZrZ4MfYs8CNfmLvR5SBVz3rISQsQ==", + "license": "ISC" + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT", + "peer": true + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "license": "MIT", + "peer": true, + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-visualizer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", + "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "license": "MIT", + "dependencies": { + "open": "^11.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^18.0.0" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/rollup-plugin-visualizer/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, + "node_modules/seek-bzip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", + "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", + "license": "MIT", + "peer": true, + "dependencies": { + "commander": "^6.0.0" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-truncate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-3.0.0.tgz", + "integrity": "sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==", + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/serve-placeholder": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/serve-placeholder/-/serve-placeholder-2.0.2.tgz", + "integrity": "sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smob": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-plain-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "sort-keys": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-dirs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-3.0.0.tgz", + "integrity": "sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "inspect-with-kind": "^1.0.5", + "is-plain-obj": "^1.1.0" + } + }, + "node_modules/strip-dirs/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", + "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", + "license": "MIT", + "dependencies": { + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/terminal-link": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-5.0.0.tgz", + "integrity": "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^4.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT", + "peer": true + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "peer": true, + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", + "integrity": "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ulid": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", + "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", + "license": "MIT", + "bin": { + "ulid": "dist/cli.js" + } + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/unbzip2-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unctx": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/unctx/-/unctx-2.5.0.tgz", + "integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==", + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21", + "unplugin": "^2.3.11" + } + }, + "node_modules/unctx/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unimport": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-6.0.2.tgz", + "integrity": "sha512-ZSOkrDw380w+KIPniY3smyXh2h7H9v2MNr9zejDuh239o5sdea44DRAYrv+rfUi2QGT186P2h0GPGKvy8avQ5g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/untun": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/untun/-/untun-0.1.3.tgz", + "integrity": "sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.5", + "consola": "^3.2.3", + "pathe": "^1.1.1" + }, + "bin": { + "untun": "bin/untun.mjs" + } + }, + "node_modules/untun/node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/untun/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, + "node_modules/untyped": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/untyped/-/untyped-2.0.0.tgz", + "integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "defu": "^6.1.4", + "jiti": "^2.4.2", + "knitwork": "^1.2.0", + "scule": "^1.3.0" + }, + "bin": { + "untyped": "dist/cli.mjs" + } + }, + "node_modules/untyped/node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/unwasm": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/unwasm/-/unwasm-0.5.3.tgz", + "integrity": "sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==", + "license": "MIT", + "dependencies": { + "exsolve": "^1.0.8", + "knitwork": "^1.3.0", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0" + } + }, + "node_modules/unwasm/node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/wcwidth/node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/workflow": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/workflow/-/workflow-4.2.0.tgz", + "integrity": "sha512-f7yXNgMsTORmvxDrrxLqDTQC3dw9wdXAJ0IQ6le7DvYSb6gmu+dggyz56mXuec6TwiZkwxzjDLaNf+uP2QXnvQ==", + "license": "Apache-2.0", + "dependencies": { + "@workflow/astro": "4.0.0", + "@workflow/cli": "4.2.0", + "@workflow/core": "4.2.0", + "@workflow/errors": "4.1.0", + "@workflow/nest": "0.0.0", + "@workflow/next": "4.0.1", + "@workflow/nitro": "4.0.1", + "@workflow/nuxt": "4.0.1", + "@workflow/rollup": "4.0.0", + "@workflow/sveltekit": "4.0.0", + "@workflow/typescript-plugin": "4.0.1", + "@workflow/utils": "4.1.0", + "ms": "2.1.3" + }, + "bin": { + "wf": "bin/run.js", + "workflow": "bin/run.js" + }, + "peerDependencies": { + "@opentelemetry/api": "1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xcase": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz", + "integrity": "sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==", + "license": "MIT" + }, + "node_modules/xdg-app-paths": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", + "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", + "license": "MIT", + "dependencies": { + "xdg-portable": "^7.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", + "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youch": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.1.tgz", + "integrity": "sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==", + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.6", + "@poppinss/dumper": "^0.7.0", + "@speed-highlight/core": "^1.2.14", + "cookie-es": "^3.0.1", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json index d2a0916..30e82c3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@chat-adapter/slack": "^4.20.2", + "@gitbeaker/rest": "^43.8.0", "@octokit/rest": "^22.0.1", "@t3-oss/env-core": "^0.13.10", "@upstash/redis": "^1.37.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f7435e..29071bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@chat-adapter/slack': specifier: ^4.20.2 version: 4.20.2 + '@gitbeaker/rest': + specifier: ^43.8.0 + version: 43.8.0 '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 @@ -354,6 +357,18 @@ packages: cpu: [x64] os: [win32] + '@gitbeaker/core@43.8.0': + resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@43.8.0': + resolution: {integrity: sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@43.8.0': + resolution: {integrity: sha512-xxqsNsUXaFang9b2e/NTIgqUeuUlifA2Opy1mOVqTDuJZZNIOTgUNyziwBJoleBhMC0XuvY3JNVMWthufcVjRw==} + engines: {node: '>=18.20.0'} + '@graphile/logger@0.2.0': resolution: {integrity: sha512-jjcWBokl9eb1gVJ85QmoaQ73CQ52xAaOCF29ukRbYNl6lY+ts0ErTaDYOBlejcbUs2OpaiqYLO5uDhyLFzWw4w==} @@ -3307,6 +3322,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -3406,6 +3425,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@8.3.0: + resolution: {integrity: sha512-mzwlfipDLlRinPgELqVDJetke6Snq26nL565m8nLWXIcWgosYSeNRgqwh7ZrZ4MfYs8CNfmLvR5SBVz3rISQsQ==} + raw-body@2.5.3: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} @@ -4179,6 +4201,9 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -4554,6 +4579,24 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@gitbeaker/core@43.8.0': + dependencies: + '@gitbeaker/requester-utils': 43.8.0 + qs: 6.14.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@43.8.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.14.2 + rate-limiter-flexible: 8.3.0 + xcase: 2.0.1 + + '@gitbeaker/rest@43.8.0': + dependencies: + '@gitbeaker/core': 43.8.0 + '@gitbeaker/requester-utils': 43.8.0 + '@graphile/logger@0.2.0': {} '@ioredis/commands@1.5.1': {} @@ -7975,6 +8018,8 @@ snapshots: picocolors@1.1.1: {} + picomatch-browser@2.2.6: {} + picomatch@2.3.1: {} picomatch@4.0.3: {} @@ -8068,6 +8113,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@8.3.0: {} + raw-body@2.5.3: dependencies: bytes: 3.1.2 @@ -8913,6 +8960,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xcase@2.0.1: {} + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 diff --git a/src/adapters/vcs/github.ts b/src/adapters/vcs/github.ts index 971ff5d..e5c4dfe 100644 --- a/src/adapters/vcs/github.ts +++ b/src/adapters/vcs/github.ts @@ -103,7 +103,7 @@ export class GitHubAdapter implements VCSAdapter { async push( branch: string, files: Array<{ path: string; content: string }>, - options?: { mergeParentSha?: string }, + options?: { mergeParentSha?: string; message?: string }, ): Promise { const { data: refData } = await this.octokit.git.getRef({ ...this.ownerRepo, @@ -147,9 +147,11 @@ export class GitHubAdapter implements VCSAdapter { const { data: newCommit } = await this.octokit.git.createCommit({ ...this.ownerRepo, - message: options?.mergeParentSha - ? "merge: resolve conflicts with base branch" - : "feat: agent implementation", + message: + options?.message ?? + (options?.mergeParentSha + ? "merge: resolve conflicts with base branch" + : "feat: agent implementation"), tree: tree.sha, parents, }); diff --git a/src/adapters/vcs/gitlab.test.ts b/src/adapters/vcs/gitlab.test.ts new file mode 100644 index 0000000..eb6a127 --- /dev/null +++ b/src/adapters/vcs/gitlab.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GitLabAdapter } from "./gitlab.js"; + +const mockBranches = { + create: vi.fn(), + remove: vi.fn(), + show: vi.fn(), +}; + +const mockRepositoryFiles = { + create: vi.fn(), + show: vi.fn(), +}; + +const mockCommits = { + create: vi.fn(), +}; + +const mockMergeRequests = { + create: vi.fn(), + all: vi.fn(), + show: vi.fn(), + allPipelines: vi.fn(), +}; + +const mockMergeRequestNotes = { + all: vi.fn(), +}; + +const mockMergeRequestDiscussions = { + all: vi.fn(), +}; + +const mockJobs = { + all: vi.fn(), + showLog: vi.fn(), +}; + +vi.mock("@gitbeaker/rest", () => ({ + Gitlab: vi.fn(() => ({ + Branches: mockBranches, + RepositoryFiles: mockRepositoryFiles, + Commits: mockCommits, + MergeRequests: mockMergeRequests, + MergeRequestNotes: mockMergeRequestNotes, + MergeRequestDiscussions: mockMergeRequestDiscussions, + Jobs: mockJobs, + })), +})); + +function glAdapter() { + return new GitLabAdapter({ + token: "glpat-xxxxxxxxxxxx", + projectId: "blazity/demo-app", + baseBranch: "main", + }); +} + +describe("GitLabAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createBranch", () => { + it("creates branch from base ref", async () => { + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockBranches.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "main", + ); + }); + + it("seeds empty repo on 404 then creates branch", async () => { + const error = new Error("404 Branch Not Found") as any; + error.cause = { response: { status: 404 } }; + mockBranches.create.mockRejectedValueOnce(error); + mockRepositoryFiles.create.mockResolvedValueOnce({ + branch: "main", + }); + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockRepositoryFiles.create).toHaveBeenCalledWith( + "blazity/demo-app", + "README.md", + "main", + "Initial commit", + "# Repository\n", + ); + expect(mockBranches.create).toHaveBeenCalledTimes(2); + }); + + it("force-resets existing branch by deleting and recreating on 400", async () => { + const error = new Error("Branch already exists") as any; + error.cause = { response: { status: 400 } }; + mockBranches.create.mockRejectedValueOnce(error); + mockBranches.remove.mockResolvedValueOnce({}); + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockBranches.remove).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + ); + expect(mockBranches.create).toHaveBeenCalledTimes(2); + }); + + it("rethrows other 400 errors (invalid ref, invalid name) without deleting branch", async () => { + const error = new Error("Invalid branch name") as any; + error.cause = { response: { status: 400 } }; + mockBranches.create.mockRejectedValueOnce(error); + + const adapter = glAdapter(); + await expect( + adapter.createBranch("bad..name", "main"), + ).rejects.toThrow("Invalid branch name"); + expect(mockBranches.remove).not.toHaveBeenCalled(); + }); + + it("handles alternate gitbeaker error shapes (response.statusCode)", async () => { + const error = new Error("404 Branch Not Found") as any; + error.response = { statusCode: 404 }; + mockBranches.create.mockRejectedValueOnce(error); + mockRepositoryFiles.create.mockResolvedValueOnce({ branch: "main" }); + mockBranches.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.createBranch("feat/test", "main"); + + expect(mockRepositoryFiles.create).toHaveBeenCalled(); + }); + }); + + describe("createPR", () => { + it("creates a merge request", async () => { + mockMergeRequests.create.mockResolvedValueOnce({ + iid: 42, + web_url: "https://gitlab.com/blazity/demo-app/-/merge_requests/42", + }); + + const adapter = glAdapter(); + const pr = await adapter.createPR("feat/test", "Add feature", "Description"); + + expect(pr.id).toBe(42); + expect(pr.url).toContain("/merge_requests/42"); + expect(pr.branch).toBe("feat/test"); + expect(mockMergeRequests.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "main", + "Add feature", + { description: "Description" }, + ); + }); + + it("throws FatalError on 409", async () => { + const error = new Error("MR already exists") as any; + error.cause = { response: { status: 409 } }; + mockMergeRequests.create.mockRejectedValueOnce(error); + + const adapter = glAdapter(); + await expect( + adapter.createPR("feat/test", "Title", "Body"), + ).rejects.toThrow("MR already exists"); + }); + + it("throws FatalError on 404", async () => { + const error = new Error("Project not found") as any; + error.cause = { response: { status: 404 } }; + mockMergeRequests.create.mockRejectedValueOnce(error); + + const adapter = glAdapter(); + await expect( + adapter.createPR("feat/test", "Title", "Body"), + ).rejects.toThrow("Project not found"); + }); + }); + + describe("push", () => { + it("marks existing files as update and new files as create", async () => { + // src/index.ts already exists on branch; src/new.ts does not. + mockRepositoryFiles.show.mockImplementation((_pid: string, path: string) => { + if (path === "src/new.ts") { + const err = new Error("404") as any; + err.cause = { response: { status: 404 } }; + return Promise.reject(err); + } + return Promise.resolve({ file_path: path }); + }); + mockCommits.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.push("feat/test", [ + { path: "src/index.ts", content: "console.log('hello');" }, + { path: "src/new.ts", content: "export const add = (a: number, b: number) => a + b;" }, + ]); + + expect(mockCommits.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "feat: agent implementation", + [ + { action: "update", filePath: "src/index.ts", content: "console.log('hello');" }, + { action: "create", filePath: "src/new.ts", content: "export const add = (a: number, b: number) => a + b;" }, + ], + ); + }); + + it("uses custom commit message when provided", async () => { + mockRepositoryFiles.show.mockResolvedValueOnce({ file_path: "a.ts" }); + mockCommits.create.mockResolvedValueOnce({}); + + const adapter = glAdapter(); + await adapter.push( + "feat/test", + [{ path: "a.ts", content: "x" }], + { message: "chore: custom message" }, + ); + + expect(mockCommits.create).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + "chore: custom message", + expect.any(Array), + ); + }); + + it("rethrows non-404 errors from file existence probe", async () => { + const err = new Error("500 Internal Server Error") as any; + err.cause = { response: { status: 500 } }; + mockRepositoryFiles.show.mockRejectedValueOnce(err); + + const adapter = glAdapter(); + await expect( + adapter.push("feat/test", [{ path: "a.ts", content: "x" }]), + ).rejects.toThrow("500 Internal Server Error"); + expect(mockCommits.create).not.toHaveBeenCalled(); + }); + + it("throws FatalError when mergeParentSha is requested (unsupported on GitLab)", async () => { + const adapter = glAdapter(); + await expect( + adapter.push( + "feat/test", + [{ path: "a.ts", content: "x" }], + { mergeParentSha: "deadbeef" }, + ), + ).rejects.toThrow(/does not support merge-commit push/); + expect(mockCommits.create).not.toHaveBeenCalled(); + }); + }); + + describe("getBranchSha", () => { + it("returns the commit SHA of a branch", async () => { + mockBranches.show.mockResolvedValueOnce({ + commit: { id: "abc123def456" }, + }); + + const adapter = glAdapter(); + const sha = await adapter.getBranchSha("feat/test"); + + expect(sha).toBe("abc123def456"); + expect(mockBranches.show).toHaveBeenCalledWith( + "blazity/demo-app", + "feat/test", + ); + }); + }); + + describe("findPR", () => { + it("returns null when no MR exists", async () => { + mockMergeRequests.all.mockResolvedValueOnce([]); + + const adapter = glAdapter(); + const pr = await adapter.findPR("feat/test"); + expect(pr).toBeNull(); + }); + + it("returns MR when one exists", async () => { + mockMergeRequests.all.mockResolvedValueOnce([ + { + iid: 42, + web_url: "https://gitlab.com/blazity/demo-app/-/merge_requests/42", + source_branch: "feat/test", + }, + ]); + + const adapter = glAdapter(); + const pr = await adapter.findPR("feat/test"); + expect(pr).not.toBeNull(); + expect(pr!.id).toBe(42); + expect(pr!.branch).toBe("feat/test"); + }); + }); + + describe("getPRComments", () => { + it("combines discussion notes and general notes", async () => { + mockMergeRequestDiscussions.all.mockResolvedValueOnce([ + { + notes: [ + { + author: { username: "reviewer1" }, + body: "Inline comment on line 10", + system: false, + type: "DiffNote", + position: { new_path: "src/index.ts", new_line: 10 }, + }, + ], + }, + ]); + mockMergeRequestNotes.all.mockResolvedValueOnce([ + { + author: { username: "reviewer2" }, + body: "General comment", + system: false, + type: null, + }, + ]); + + const adapter = glAdapter(); + const comments = await adapter.getPRComments(42); + + expect(comments).toHaveLength(2); + expect(comments[0]).toEqual({ + author: "reviewer1", + body: "Inline comment on line 10", + liked: false, + filePath: "src/index.ts", + startLine: 10, + endLine: 10, + }); + expect(comments[1]).toEqual({ + author: "reviewer2", + body: "General comment", + liked: false, + }); + }); + }); + + describe("getCheckRunResults", () => { + it("maps GitLab CI job statuses to CheckRunResult", async () => { + mockMergeRequests.allPipelines.mockResolvedValueOnce([ + { id: 100, status: "failed" }, + ]); + mockJobs.all.mockResolvedValueOnce([ + { id: 1, name: "lint", status: "success" }, + { id: 2, name: "test", status: "failed" }, + { id: 3, name: "build", status: "running" }, + ]); + mockJobs.showLog.mockResolvedValueOnce("Error: test failed on line 42"); + + const adapter = glAdapter(); + const results = await adapter.getCheckRunResults(42); + + expect(results).toHaveLength(3); + expect(results[0]).toEqual({ + name: "lint", + status: "completed", + conclusion: "success", + }); + expect(results[1]).toEqual({ + name: "test", + status: "completed", + conclusion: "failure", + logs: "Error: test failed on line 42", + }); + expect(results[2]).toEqual({ + name: "build", + status: "in_progress", + conclusion: null, + }); + }); + }); + + describe("getPRConflictStatus", () => { + it("returns true when MR has conflicts", async () => { + mockMergeRequests.show.mockResolvedValueOnce({ has_conflicts: true }); + + const adapter = glAdapter(); + const hasConflicts = await adapter.getPRConflictStatus(42); + expect(hasConflicts).toBe(true); + }); + + it("returns false when MR has no conflicts", async () => { + mockMergeRequests.show.mockResolvedValueOnce({ has_conflicts: false }); + + const adapter = glAdapter(); + const hasConflicts = await adapter.getPRConflictStatus(42); + expect(hasConflicts).toBe(false); + }); + }); +}); diff --git a/src/adapters/vcs/gitlab.ts b/src/adapters/vcs/gitlab.ts new file mode 100644 index 0000000..d172532 --- /dev/null +++ b/src/adapters/vcs/gitlab.ts @@ -0,0 +1,323 @@ +import { Gitlab } from "@gitbeaker/rest"; +import { FatalError } from "workflow"; +import type { + VCSAdapter, + PullRequest, + PRComment, + CheckRunResult, +} from "./types.js"; + +// Minimal shapes for gitbeaker responses we touch. Declared locally so we do +// not depend on gitbeaker's deep generic return types, which have changed +// across versions. Only the fields we actually read are listed. +interface GitLabMR { + iid: number; + web_url: string; + source_branch: string; +} +interface GitLabNotePosition { + new_path?: string; + new_line?: number; + old_path?: string; + old_line?: number; +} +interface GitLabNote { + system?: boolean; + type?: string; + author?: { username?: string }; + body?: string; + position?: GitLabNotePosition; +} +interface GitLabDiscussion { + notes?: GitLabNote[]; +} +interface GitLabJob { + id: number; + name: string; + status: string; +} + +export interface GitLabConfig { + token: string; + projectId: string; + baseBranch: string; + /** Base URL for GitLab instance. Defaults to "https://gitlab.com". */ + host?: string; +} + +export class GitLabAdapter implements VCSAdapter { + private gl: InstanceType; + private projectId: string; + private baseBranch: string; + + constructor(private config: GitLabConfig) { + this.gl = new Gitlab({ + token: config.token, + ...(config.host ? { host: config.host } : {}), + }); + this.projectId = config.projectId; + this.baseBranch = config.baseBranch; + } + + async createBranch(name: string, base: string): Promise { + try { + await this.gl.Branches.create(this.projectId, name, base); + } catch (err: any) { + const status = this.getStatusCode(err); + + if (status === 404) { + await this.seedEmptyRepo(base); + await this.gl.Branches.create(this.projectId, name, base); + return; + } + + // GitLab returns 400 for many validation errors. Only treat it as + // "branch already exists" when the message says so; rethrow otherwise + // so invalid-ref / invalid-name errors do not silently destroy branches. + if (status === 400 && /already exists/i.test(String(err?.message ?? ""))) { + await this.gl.Branches.remove(this.projectId, name); + await this.gl.Branches.create(this.projectId, name, base); + return; + } + + throw err; + } + } + + private async seedEmptyRepo(branch: string): Promise { + try { + await this.gl.RepositoryFiles.create( + this.projectId, + "README.md", + branch, + "Initial commit", + "# Repository\n", + ); + } catch (err: any) { + throw new Error( + `Failed to seed empty repository ${this.projectId}: ${err.message}`, + ); + } + } + + private getStatusCode(err: any): number | undefined { + // gitbeaker error shapes vary across versions and transports: + // - fetch-based: err.cause.response.status + // - got-based: err.response.statusCode / err.response.status + // - normalized: err.status / err.statusCode + return ( + err?.cause?.response?.status ?? + err?.response?.status ?? + err?.response?.statusCode ?? + err?.status ?? + err?.statusCode + ); + } + + async createPR( + branch: string, + title: string, + body: string, + ): Promise { + try { + const mr = await this.gl.MergeRequests.create( + this.projectId, + branch, + this.baseBranch, + title, + { description: body }, + ); + return { id: mr.iid, url: String(mr.web_url), branch }; + } catch (err: any) { + const status = this.getStatusCode(err); + if (status === 409 || status === 404) { + throw new FatalError(err.message); + } + throw err; + } + } + + async push( + branch: string, + files: Array<{ path: string; content: string }>, + options?: { mergeParentSha?: string; message?: string }, + ): Promise { + // GitLab's REST commits API creates linear commits only — it has no + // equivalent to GitHub's two-parent createCommit for reconciling branch + // histories. Conflict resolution on GitLab should go through an MR rebase + // (MergeRequests.rebase) or an explicit merge, which is not part of this + // adapter's push() contract. Fail loudly instead of silently producing a + // single-parent commit that leaves the MR in a conflicted state. + if (options?.mergeParentSha) { + throw new FatalError( + "GitLab adapter does not support merge-commit push (mergeParentSha). " + + "Conflict resolution requires MR rebase and is not yet implemented.", + ); + } + + // GitLab's REST commits API has no "upsert" action — each file must be + // declared as either "create" or "update". Probe each path on the target + // branch: 404 → create, otherwise update. Done in parallel to avoid a + // linear-in-file-count latency hit. + const actions = await Promise.all( + files.map(async (f) => { + const exists = await this.fileExistsOnBranch(f.path, branch); + return { + action: (exists ? "update" : "create") as "update" | "create", + filePath: f.path, + content: f.content, + }; + }), + ); + + await this.gl.Commits.create( + this.projectId, + branch, + options?.message ?? "feat: agent implementation", + actions, + ); + } + + private async fileExistsOnBranch( + filePath: string, + branch: string, + ): Promise { + try { + await this.gl.RepositoryFiles.show(this.projectId, filePath, branch); + return true; + } catch (err: unknown) { + if (this.getStatusCode(err) === 404) return false; + throw err; + } + } + + async getBranchSha(branch: string): Promise { + const data = await this.gl.Branches.show(this.projectId, branch); + return (data.commit as { id: string }).id; + } + + async findPR(branch: string): Promise { + const mrs = (await this.gl.MergeRequests.all({ + projectId: this.projectId, + sourceBranch: branch, + state: "opened", + })) as unknown as GitLabMR[]; + if (mrs.length === 0) return null; + const mr = mrs[0]; + return { id: mr.iid, url: mr.web_url, branch: mr.source_branch }; + } + + async getPRComments(prId: number): Promise { + const comments: PRComment[] = []; + + const discussions = (await this.gl.MergeRequestDiscussions.all( + this.projectId, + prId, + )) as unknown as GitLabDiscussion[]; + for (const discussion of discussions) { + for (const note of discussion.notes ?? []) { + if (note.system) continue; + if (note.type !== "DiffNote") continue; + comments.push({ + author: note.author?.username ?? "unknown", + body: String(note.body ?? ""), + // GitLab notes have no direct "liked" signal comparable to GitHub + // reactions. Intentionally hardcoded — see design spec. + liked: false, + // Comments on deleted lines only have old_path/old_line — + // fall back so the anchor isn't lost. + filePath: note.position?.new_path ?? note.position?.old_path, + startLine: note.position?.new_line ?? note.position?.old_line, + endLine: note.position?.new_line ?? note.position?.old_line, + }); + } + } + + const notes = (await this.gl.MergeRequestNotes.all( + this.projectId, + prId, + )) as unknown as GitLabNote[]; + for (const note of notes) { + if (note.system) continue; + if (note.type === "DiffNote") continue; + comments.push({ + author: note.author?.username ?? "unknown", + body: String(note.body ?? ""), + // See note above — liked is intentionally hardcoded for GitLab. + liked: false, + }); + } + + return comments; + } + + async getCheckRunResults(prId: number): Promise { + const pipelines = await this.gl.MergeRequests.allPipelines( + this.projectId, + prId, + ); + + if (pipelines.length === 0) return []; + + const latestPipeline = pipelines[0]; + const jobs = (await this.gl.Jobs.all(this.projectId, { + pipelineId: latestPipeline.id, + })) as unknown as GitLabJob[]; + + const results: CheckRunResult[] = []; + for (const job of jobs) { + const mapped = this.mapJobStatus(job.status); + const entry: CheckRunResult = { + name: job.name, + status: mapped.status, + conclusion: mapped.conclusion, + }; + + if ( + mapped.status === "completed" && + mapped.conclusion !== "success" && + mapped.conclusion !== null && + mapped.conclusion !== "skipped" && + mapped.conclusion !== "cancelled" + ) { + try { + const log = await this.gl.Jobs.showLog(this.projectId, job.id); + entry.logs = String(log); + } catch { + // Log fetching is best-effort + } + } + + results.push(entry); + } + + return results; + } + + private mapJobStatus( + status: string, + ): Pick { + switch (status) { + case "success": + return { status: "completed", conclusion: "success" }; + case "failed": + return { status: "completed", conclusion: "failure" }; + case "running": + return { status: "in_progress", conclusion: null }; + case "pending": + case "created": + return { status: "queued", conclusion: null }; + case "canceled": + return { status: "completed", conclusion: "cancelled" }; + case "skipped": + return { status: "completed", conclusion: "skipped" }; + default: + return { status: "queued", conclusion: null }; + } + } + + async getPRConflictStatus(prId: number): Promise { + const mr = await this.gl.MergeRequests.show(this.projectId, prId); + return (mr as { has_conflicts?: boolean }).has_conflicts === true; + } +} diff --git a/src/adapters/vcs/types.ts b/src/adapters/vcs/types.ts index 32e223f..db1921e 100644 --- a/src/adapters/vcs/types.ts +++ b/src/adapters/vcs/types.ts @@ -26,7 +26,7 @@ export interface VCSAdapter { push( branch: string, files: Array<{ path: string; content: string }>, - options?: { mergeParentSha?: string }, + options?: { mergeParentSha?: string; message?: string }, ): Promise; getPRComments(prId: number): Promise; getCheckRunResults(prId: number): Promise; diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 4e762d8..115c056 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -1,8 +1,8 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; -import { GitHubAdapter } from "../adapters/vcs/github.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; +import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; import type { MessagingAdapter } from "../adapters/messaging/types.js"; @@ -23,12 +23,7 @@ export function createAdapters(): Adapters { apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, }), - vcs: new GitHubAdapter({ - token: env.GITHUB_TOKEN, - owner: env.GITHUB_OWNER, - repo: env.GITHUB_REPO, - baseBranch: env.GITHUB_BASE_BRANCH, - }), + vcs: createVCS(), messaging: new ChatSDKAdapter({ slackToken: env.CHAT_SDK_SLACK_TOKEN, channelId: env.CHAT_SDK_CHANNEL_ID, diff --git a/src/lib/create-vcs.ts b/src/lib/create-vcs.ts new file mode 100644 index 0000000..0922f33 --- /dev/null +++ b/src/lib/create-vcs.ts @@ -0,0 +1,27 @@ +import { getVcsConfig } from "../../env.js"; +import { GitHubAdapter } from "../adapters/vcs/github.js"; +import { GitLabAdapter } from "../adapters/vcs/gitlab.js"; +import type { VCSAdapter } from "../adapters/vcs/types.js"; + +export function createVCS(): VCSAdapter { + const vcs = getVcsConfig(); + if (vcs.kind === "gitlab") { + return new GitLabAdapter({ + token: vcs.token, + projectId: vcs.repoPath, + baseBranch: vcs.baseBranch, + host: vcs.host, + }); + } + const parts = vcs.repoPath.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid repoPath for GitHub: expected exactly "owner/repo", got "${vcs.repoPath}"`); + } + const [owner, repo] = parts; + return new GitHubAdapter({ + token: vcs.token, + owner, + repo, + baseBranch: vcs.baseBranch, + }); +} diff --git a/src/lib/step-adapters.ts b/src/lib/step-adapters.ts index 8ad7625..bb3617e 100644 --- a/src/lib/step-adapters.ts +++ b/src/lib/step-adapters.ts @@ -1,8 +1,8 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; -import { GitHubAdapter } from "../adapters/vcs/github.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; +import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; import type { MessagingAdapter } from "../adapters/messaging/types.js"; @@ -23,12 +23,7 @@ export function createStepAdapters(): StepAdapters { apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, }), - vcs: new GitHubAdapter({ - token: env.GITHUB_TOKEN, - owner: env.GITHUB_OWNER, - repo: env.GITHUB_REPO, - baseBranch: env.GITHUB_BASE_BRANCH, - }), + vcs: createVCS(), messaging: new ChatSDKAdapter({ slackToken: env.CHAT_SDK_SLACK_TOKEN, channelId: env.CHAT_SDK_CHANNEL_ID, diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index 981ce88..284e708 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -36,9 +36,10 @@ describe("SandboxManager", () => { const { Sandbox } = await import("@vercel/sandbox"); const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", anthropicApiKey: "sk-ant-test", claudeModel: "claude-opus-4-6", commitAuthor: "ai-workflow-blazity", @@ -64,9 +65,10 @@ describe("SandboxManager", () => { it("writes agent-env.sh with auth credentials during provision", async () => { const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", anthropicApiKey: "sk-ant-test", claudeModel: "claude-opus-4-6", commitAuthor: "ai-workflow-blazity", @@ -89,9 +91,10 @@ describe("SandboxManager", () => { it("writes CLAUDE_CODE_OAUTH_TOKEN when OAuth token is provided", async () => { const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", claudeCodeOauthToken: "oauth-token-test", claudeModel: "claude-opus-4-6", commitAuthor: "ai-workflow-blazity", @@ -110,9 +113,10 @@ describe("SandboxManager", () => { it("configures stop hook when enabled", async () => { const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", anthropicApiKey: "sk-ant-test", claudeModel: "claude-opus-4-6", commitAuthor: "ai-workflow-blazity", @@ -131,9 +135,10 @@ describe("SandboxManager", () => { it("clears stop hook when disabled", async () => { const manager = new SandboxManager({ - githubToken: "ghp_test", - owner: "test-org", - repo: "test-repo", + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", anthropicApiKey: "sk-ant-test", claudeModel: "claude-opus-4-6", commitAuthor: "ai-workflow-blazity", diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index f9a6c5d..e62cfc2 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -12,9 +12,12 @@ const GLOBAL_SKILLS = [ ] as const; export interface SandboxConfig { - githubToken: string; - owner: string; - repo: string; + kind: "github" | "gitlab"; + token: string; + /** GitHub: "owner/repo", GitLab: project path e.g. "group/repo" */ + repoPath: string; + /** VCS host base URL, e.g. https://github.com or https://gitlab.example.com */ + host: string; anthropicApiKey?: string; claudeCodeOauthToken?: string; claudeModel: string; @@ -23,6 +26,29 @@ export interface SandboxConfig { jobTimeoutMs: number; } +/** Build clone/push URLs for the configured VCS. Supports github.com and any GitLab host (incl. self-hosted). */ +export function buildVcsUrls(config: { + kind: "github" | "gitlab"; + token: string; + repoPath: string; + host: string; +}) { + // Strip trailing slash for consistent URL joining. + const host = config.host.replace(/\/+$/, ""); + // Preserve the scheme from the configured host so cloneUrl and authUrl agree + // (e.g. http:// for a self-hosted GitLab dev instance must not silently + // become https:// in authUrl). + const scheme = host.match(/^https?:\/\//)?.[0] ?? "https://"; + // Extract `host.tld` (no scheme) so we can interpolate credentials into the URL. + const hostNoScheme = host.replace(/^https?:\/\//, ""); + const authUser = config.kind === "gitlab" ? "oauth2" : "x-access-token"; + return { + cloneUrl: `${host}/${config.repoPath}.git`, + authUrl: `${scheme}${authUser}:${config.token}@${hostNoScheme}/${config.repoPath}.git`, + authUser, + }; +} + type SandboxInstance = Awaited>; /** Minimal interface for sandbox objects that support runCommand (works with both Sandbox.create and Sandbox.get). */ @@ -79,13 +105,15 @@ export class SandboxManager { throw new Error("Either anthropicApiKey or claudeCodeOauthToken must be provided"); } + const urls = buildVcsUrls(this.config); + const sandbox = await Sandbox.create({ ...getSandboxCredentials(), source: { type: "git", - url: `https://github.com/${this.config.owner}/${this.config.repo}.git`, - username: "x-access-token", - password: this.config.githubToken, + url: urls.cloneUrl, + username: urls.authUser, + password: this.config.token, revision: branch, }, runtime: "node24", @@ -101,8 +129,7 @@ export class SandboxManager { // Strip auth from origin — the clone URL contains the token, replace it // with the unauthenticated URL so the agent never has push access. await sandbox.runCommand("git", [ - "remote", "set-url", "origin", - `https://github.com/${this.config.owner}/${this.config.repo}.git`, + "remote", "set-url", "origin", urls.cloneUrl, ]); // The sandbox clones a specific revision, which leaves git in detached HEAD. @@ -118,7 +145,7 @@ export class SandboxManager { // Merge base branch so the agent can see and resolve conflicts. // The shallow clone has no remote, so we fetch directly via authenticated URL. if (mergeBase) { - const repoUrl = `https://x-access-token:${this.config.githubToken}@github.com/${this.config.owner}/${this.config.repo}.git`; + const repoUrl = urls.authUrl; const fetchResult = await sandbox.runCommand("bash", [ "-c", `git fetch "${repoUrl}" ${mergeBase} 2>&1`, @@ -134,7 +161,11 @@ export class SandboxManager { ]); if (mergeResult.exitCode !== 0) { const mergeOutput = (await mergeResult.stdout()).trim(); - console.warn(`Merge of ${mergeBase} had conflicts (exit=${mergeResult.exitCode}): ${mergeOutput}`); + const { logger } = await import("../lib/logger.js"); + logger.warn( + { mergeBase, exitCode: mergeResult.exitCode, output: mergeOutput.slice(0, 500) }, + "merge_conflicts_during_provision", + ); } } diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index 5ebce5c..4120f27 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -23,19 +23,57 @@ vi.mock("./credentials.js", () => ({ getSandboxCredentials: () => ({}), })); +// VCS config is swapped per-test by reassigning currentVcsConfig before the +// step under test calls getVcsConfig(). Default is GitHub; GitLab tests set +// it to a GitLab config to exercise the oauth2 auth user and gitlab host. +let currentVcsConfig: { + kind: "github" | "gitlab"; + token: string; + repoPath: string; + baseBranch: string; + host: string; +} = { + kind: "github", + token: "ghp_test_token", + repoPath: "test-owner/test-repo", + baseBranch: "main", + host: "https://github.com", +}; + +const githubVcsConfig = { + kind: "github" as const, + token: "ghp_test_token", + repoPath: "test-owner/test-repo", + baseBranch: "main", + host: "https://github.com", +}; + +const gitlabVcsConfig = { + kind: "gitlab" as const, + token: "glpat_test_token", + repoPath: "test-group/test-repo", + baseBranch: "main", + host: "https://gitlab.example.com", +}; + vi.mock("../../env.js", () => ({ env: { + VCS_KIND: "github", GITHUB_TOKEN: "ghp_test_token", GITHUB_OWNER: "test-owner", GITHUB_REPO: "test-repo", CLAUDE_MODEL: "claude-sonnet-4-20250514", }, + getVcsConfig: () => currentVcsConfig, })); import { pushFromSandbox, fixAndRetryPush, teardownSandbox, checkPhaseDone, collectPhaseOutput } from "./poll-agent.js"; describe("pushFromSandbox", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + currentVcsConfig = githubVcsConfig; + }); it("returns error when agent made no commits", async () => { const mockStdout = vi.fn(); @@ -107,6 +145,36 @@ describe("pushFromSandbox", () => { expect(result.error).toBe("pre-push hook declined"); }); + it("uses GitLab oauth2 auth user and host when VCS_KIND=gitlab", async () => { + currentVcsConfig = gitlabVcsConfig; + const callIndex = { value: 0 }; + mockRunCommand.mockImplementation(() => { + const i = callIndex.value++; + if (i === 0) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("abc123") }; + } else if (i === 1) { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue("def456") }; + } else { + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(""), stderr: vi.fn().mockResolvedValue("") }; + } + }); + + const result = await pushFromSandbox("sbx-test-123", "blazebot/task-1"); + + expect(result.pushed).toBe(true); + // Auth URL should use oauth2 + GitLab host (not x-access-token + github.com). + expect(mockRunCommand).toHaveBeenCalledWith( + "git", + [ + "remote", + "set-url", + "origin", + "https://oauth2:glpat_test_token@gitlab.example.com/test-group/test-repo.git", + ], + ); + expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "--force", "origin", "HEAD:refs/heads/blazebot/task-1"]); + }); + it("pushes anyway when sentinel file is missing", async () => { const callIndex = { value: 0 }; mockRunCommand.mockImplementation(() => { @@ -131,7 +199,10 @@ describe("pushFromSandbox", () => { }); describe("fixAndRetryPush", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + currentVcsConfig = githubVcsConfig; + }); it("writes prompt to file and retries push successfully", async () => { const callIndex = { value: 0 }; diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index d37d0fd..848f99f 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -1,7 +1,8 @@ import { getSandboxCredentials } from "./credentials.js"; +import { buildVcsUrls } from "./manager.js"; /** - * After the agent exits, injects the GitHub token and pushes commits to GitHub. + * After the agent exits, injects the VCS token and pushes commits. * The agent process is dead at this point — the token is never visible to it. */ export async function pushFromSandbox( @@ -10,8 +11,9 @@ export async function pushFromSandbox( ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); - const { env } = await import("../../env.js"); + const { getVcsConfig } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const urls = buildVcsUrls(getVcsConfig()); // Check if agent made any commits. // If the sentinel file is missing (provisioning issue), skip the check and push anyway. @@ -27,8 +29,7 @@ export async function pushFromSandbox( } // Inject token — agent process is dead - const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; - await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + await sandbox.runCommand("git", ["remote", "set-url", "origin", urls.authUrl]); // Unshallow if needed — shallow clones cause "no history in common with main" // errors on PR creation because the pushed commits lack shared ancestry. @@ -37,7 +38,7 @@ export async function pushFromSandbox( 'if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then git fetch --unshallow origin; fi', ]); - // Push to GitHub — use HEAD: so it works even if the local branch name + // Push to remote — use HEAD: so it works even if the local branch name // doesn't match. Use --force for retries where the branch already has commits // from a prior failed run. Safe because these are bot-created branches with // no concurrent pushers. @@ -67,13 +68,13 @@ export async function fixAndRetryPush( ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); - const { env } = await import("../../env.js"); + const { env, getVcsConfig } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const urls = buildVcsUrls(getVcsConfig()); // Strip token from origin before the fix agent runs — agent only commits, never pushes. await sandbox.runCommand("git", [ - "remote", "set-url", "origin", - `https://github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`, + "remote", "set-url", "origin", urls.cloneUrl, ]); // Write prompt to a file to avoid shell injection via pushError content @@ -88,15 +89,15 @@ export async function fixAndRetryPush( ]); // Log fix agent output for observability + const { logger } = await import("../lib/logger.js"); const fixOut = await sandbox.runCommand("cat", ["/tmp/fix-stdout.txt"]); const fixLog = (await fixOut.stdout()).trim(); if (fixLog) { - console.log(`[fixAndRetryPush] fix agent output: ${fixLog.slice(0, 500)}`); + logger.info({ output: fixLog.slice(0, 500) }, "fix_and_retry_push_output"); } // Re-inject token and push — server pushes, not the agent. - const pushUrl = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_OWNER}/${env.GITHUB_REPO}.git`; - await sandbox.runCommand("git", ["remote", "set-url", "origin", pushUrl]); + await sandbox.runCommand("git", ["remote", "set-url", "origin", urls.authUrl]); const result = await sandbox.runCommand("git", ["push", "--force", "origin", `HEAD:refs/heads/${branch}`]); diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 04d8781..b81b767 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -44,13 +44,28 @@ async function provisionSandbox( mergeBase?: string, ): Promise { "use step"; - const { env } = await import("../../env.js"); + const { env, getVcsConfig } = await import("../../env.js"); const { SandboxManager } = await import("../sandbox/manager.js"); + const vcs = getVcsConfig(); + + // The sandbox builds clone/push URLs by interpolating repoPath into a URL, + // so it must be a URL-safe namespace/project path (e.g. "group/repo"). + // GitLab also accepts numeric project IDs in its REST API, but those produce + // invalid clone URLs like "https://gitlab.com/12345.git". Fail fast with a + // clear message rather than producing a confusing git clone error. + if (vcs.kind === "gitlab" && /^\d+$/.test(vcs.repoPath)) { + throw new Error( + `GITLAB_PROJECT_ID must be a namespace/project path (e.g. "group/repo"), ` + + `not a numeric project ID ("${vcs.repoPath}"). Numeric IDs work for the ` + + `GitLab REST API but cannot be used to construct a git clone URL.`, + ); + } const manager = new SandboxManager({ - githubToken: env.GITHUB_TOKEN, - owner: env.GITHUB_OWNER, - repo: env.GITHUB_REPO, + kind: vcs.kind, + token: vcs.token, + repoPath: vcs.repoPath, + host: vcs.host, anthropicApiKey: env.ANTHROPIC_API_KEY, claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, claudeModel: env.CLAUDE_MODEL, @@ -202,7 +217,7 @@ const MAX_REVIEW_RETRIES = 2; export async function agentWorkflow(ticketId: string) { "use workflow"; - const { env } = await import("../../env.js"); + const { env, getVcsConfig } = await import("../../env.js"); const { getPrompt } = await import("../lib/prompts.js"); const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); const { parseResearchStatus, parseAgentOutput, parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = @@ -227,13 +242,15 @@ export async function agentWorkflow(ticketId: string) { // GitHub to auto-close any open PR (no diff = no PR). const prContext = await fetchPRContext(branchName); + const baseBranch = getVcsConfig().baseBranch; + if (!prContext) { // New ticket — create (or reset) the branch from base - await createFeatureBranch(branchName, env.GITHUB_BASE_BRANCH); + await createFeatureBranch(branchName, baseBranch); } // Review-fix: branch + PR already exist, keep the branch as-is - const mergeBase = prContext?.hasConflicts ? env.GITHUB_BASE_BRANCH : undefined; + const mergeBase = prContext?.hasConflicts ? baseBranch : undefined; // Provision sandbox once for all phases const sandboxId = await provisionSandbox(branchName, mergeBase); From bd569808c7988a29a74d605cdbc68c25681557be Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:42:43 +0200 Subject: [PATCH 25/71] feat: harden poll logic (#52) * feat: harden poll logic * fix: mock env in dispatch * fix: ghost workflow logs * feat: implement IssueTrackerNotFoundError handling in JiraAdapter and related tests --- src/adapters/issue-tracker/jira.test.ts | 14 +++ src/adapters/issue-tracker/jira.ts | 20 +++- src/adapters/issue-tracker/types.ts | 14 +++ src/lib/dispatch.test.ts | 39 ++++++- src/lib/dispatch.ts | 43 +++++++- src/lib/reconcile.test.ts | 132 ++++++++++++++++++++++++ src/lib/reconcile.ts | 69 +++++++++++++ src/routes/cron/poll.get.ts | 35 ++++++- src/routes/webhooks/jira.post.ts | 107 ++++++++++++++++++- 9 files changed, 465 insertions(+), 8 deletions(-) diff --git a/src/adapters/issue-tracker/jira.test.ts b/src/adapters/issue-tracker/jira.test.ts index 54f8849..6c87fa1 100644 --- a/src/adapters/issue-tracker/jira.test.ts +++ b/src/adapters/issue-tracker/jira.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { JiraAdapter } from "./jira.js"; +import { IssueTrackerNotFoundError } from "./types.js"; const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -48,6 +49,19 @@ describe("JiraAdapter", () => { expect(ticket.comments).toHaveLength(1); expect(ticket.trackerStatus).toBe("AI"); }); + + it("throws IssueTrackerNotFoundError on 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + const adapter = jiraAdapter(); + await expect(adapter.fetchTicket("10001")).rejects.toBeInstanceOf( + IssueTrackerNotFoundError, + ); + }); }); describe("searchTickets", () => { diff --git a/src/adapters/issue-tracker/jira.ts b/src/adapters/issue-tracker/jira.ts index da52498..659e9ba 100644 --- a/src/adapters/issue-tracker/jira.ts +++ b/src/adapters/issue-tracker/jira.ts @@ -1,4 +1,9 @@ -import type { IssueTrackerAdapter, TicketContent, TicketComment } from "./types.js"; +import { + IssueTrackerNotFoundError, + type IssueTrackerAdapter, + type TicketContent, + type TicketComment, +} from "./types.js"; export interface JiraConfig { baseUrl: string; @@ -28,6 +33,9 @@ export class JiraAdapter implements IssueTrackerAdapter { }, }); if (!res.ok) { + if (res.status === 404) { + throw new IssueTrackerNotFoundError("Jira resource", path); + } throw new Error(`Jira API error: ${res.status} ${res.statusText} on ${path}`); } if (res.status === 204) return null; @@ -40,11 +48,12 @@ export class JiraAdapter implements IssueTrackerAdapter { async fetchTicket(id: string): Promise { const data = await this.request( - `/rest/api/3/issue/${id}?fields=summary,description,comment,labels,status`, + `/rest/api/3/issue/${id}?fields=summary,description,comment,labels,status,project`, ); return { id: data.id, identifier: data.key, + projectKey: data.fields.project?.key ?? extractProjectKey(data.key), title: data.fields.summary ?? "", description: extractAdfText(data.fields.description), acceptanceCriteria: extractAcceptanceCriteria(data.fields.description), @@ -117,3 +126,10 @@ function extractAcceptanceCriteria(description: any): string { const match = text.match(/acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i); return match?.[1]?.trim() ?? ""; } + +function extractProjectKey(identifier: string): string | undefined { + if (!identifier) return undefined; + const dash = identifier.indexOf("-"); + if (dash <= 0) return undefined; + return identifier.slice(0, dash).toUpperCase(); +} diff --git a/src/adapters/issue-tracker/types.ts b/src/adapters/issue-tracker/types.ts index 93e02bd..9299a5d 100644 --- a/src/adapters/issue-tracker/types.ts +++ b/src/adapters/issue-tracker/types.ts @@ -1,6 +1,7 @@ export interface TicketContent { id: string; identifier: string; + projectKey?: string; title: string; description: string; acceptanceCriteria: string; @@ -9,6 +10,15 @@ export interface TicketContent { trackerStatus: string; } +export class IssueTrackerNotFoundError extends Error { + readonly code = "NOT_FOUND"; + + constructor(resource: string, id: string) { + super(`${resource} not found: ${id}`); + this.name = "IssueTrackerNotFoundError"; + } +} + export interface TicketComment { author: string; body: string; @@ -16,6 +26,10 @@ export interface TicketComment { } export interface IssueTrackerAdapter { + /** + * Fetch a single ticket by key/id. + * Throws IssueTrackerNotFoundError (code: NOT_FOUND) when the ticket does not exist. + */ fetchTicket(id: string): Promise; moveTicket(id: string, column: string): Promise; postComment(id: string, comment: string): Promise; diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 86f4bae..9e47d66 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Adapters } from "./adapters.js"; import type { TicketContent } from "../adapters/issue-tracker/types.js"; +vi.mock("../../env.js", () => ({ + env: { + JIRA_PROJECT_KEY: "PROJ", + COLUMN_AI: "AI", + }, +})); + const mockStart = vi.fn(); const mockGetRun = vi.fn(); vi.mock("workflow/api", () => ({ @@ -98,7 +105,7 @@ describe("dispatchTicket", () => { mockStart.mockResolvedValue({ runId: "run_123" }); }); - it("dispatches agentWorkflow for any ticket", async () => { + it("dispatches agentWorkflow for a ticket in configured project + AI column", async () => { const adapters = makeAdapters(); const { dispatchTicket } = await import("./dispatch.js"); @@ -119,6 +126,36 @@ describe("dispatchTicket", () => { ); }); + it("skips dispatch when ticket is no longer in AI column", async () => { + const unregister = vi.fn().mockResolvedValue(undefined); + const adapters = makeAdapters({ + fetchTicket: vi.fn().mockResolvedValue(makeTicket({ trackerStatus: "Backlog" })), + unregister, + }); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 5); + + expect(result).toEqual({ started: false, reason: "not_in_ai_column" }); + expect(unregister).toHaveBeenCalledWith("PROJ-42"); + expect(mockStart).not.toHaveBeenCalled(); + }); + + it("skips dispatch when ticket is outside configured Jira project key", async () => { + const unregister = vi.fn().mockResolvedValue(undefined); + const adapters = makeAdapters({ + fetchTicket: vi.fn().mockResolvedValue(makeTicket({ identifier: "OTHER-42" })), + unregister, + }); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 5); + + expect(result).toEqual({ started: false, reason: "wrong_project_key" }); + expect(unregister).toHaveBeenCalledWith("PROJ-42"); + expect(mockStart).not.toHaveBeenCalled(); + }); + it("returns already_claimed when claim fails", async () => { const adapters = makeAdapters({ claim: vi.fn().mockResolvedValue(false), diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index f672477..c582ed9 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -1,4 +1,5 @@ import { start, getRun } from "workflow/api"; +import { env } from "../../env.js"; import { agentWorkflow } from "../workflows/agent.js"; import { logger } from "./logger.js"; import type { Adapters } from "./adapters.js"; @@ -16,7 +17,13 @@ export function getClaimTimestamp(runId: string): number { export interface DispatchResult { started: boolean; runId?: string; - reason?: "already_claimed" | "at_capacity" | "error" | "previously_failed"; + reason?: + | "already_claimed" + | "at_capacity" + | "error" + | "previously_failed" + | "not_in_ai_column" + | "wrong_project_key"; } export async function dispatchTicket( @@ -24,6 +31,8 @@ export async function dispatchTicket( adapters: Adapters, maxConcurrentAgents: number, ): Promise { + const expectedProjectKey = env.JIRA_PROJECT_KEY.trim().toUpperCase(); + const expectedAiStatus = env.COLUMN_AI.trim().toLowerCase(); const { issueTracker, runRegistry } = adapters; if (await runRegistry.isTicketFailed(ticketKey)) { @@ -44,6 +53,30 @@ export async function dispatchTicket( try { const ticket = await issueTracker.fetchTicket(ticketKey); + const ticketStatus = ticket.trackerStatus.trim().toLowerCase(); + if (ticketStatus !== expectedAiStatus) { + await runRegistry.unregister(ticketKey).catch(() => {}); + logger.info( + { ticketKey, ticketStatus: ticket.trackerStatus, expectedStatus: env.COLUMN_AI }, + "dispatch_skipped_not_in_ai_column", + ); + return { started: false, reason: "not_in_ai_column" }; + } + + const ticketProjectKey = extractProjectKey(ticket.identifier); + if (!ticketProjectKey || ticketProjectKey !== expectedProjectKey) { + await runRegistry.unregister(ticketKey).catch(() => {}); + logger.info( + { + ticketKey, + ticketIdentifier: ticket.identifier, + ticketProjectKey, + expectedProjectKey: env.JIRA_PROJECT_KEY, + }, + "dispatch_skipped_wrong_project_key", + ); + return { started: false, reason: "wrong_project_key" }; + } const handle = await start(agentWorkflow, [ticket.id]); logger.info( @@ -108,3 +141,11 @@ async function abortWorkflow(runId: string, ticketKey: string): Promise { await run.cancel(); } catch {} } + +function extractProjectKey(ticketIdentifier: string): string | null { + const trimmed = ticketIdentifier.trim(); + if (!trimmed) return null; + const dashIndex = trimmed.indexOf("-"); + if (dashIndex <= 0) return null; + return trimmed.slice(0, dashIndex).toUpperCase(); +} diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index eef24ac..0dbb9eb 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + IssueTrackerNotFoundError, + type IssueTrackerAdapter, +} from "../adapters/issue-tracker/types.js"; import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; +vi.mock("../../env.js", () => ({ + env: { + JIRA_PROJECT_KEY: "PROJ", + COLUMN_AI: "AI", + }, +})); + const mockGetRun = vi.fn(); vi.mock("workflow/api", () => ({ getRun: (...args: any[]) => mockGetRun(...args), @@ -28,6 +39,18 @@ function makeRegistry( }; } +function makeIssueTracker( + overrides: Partial = {}, +): IssueTrackerAdapter { + return { + fetchTicket: vi.fn(), + moveTicket: vi.fn(), + postComment: vi.fn(), + searchTickets: vi.fn(), + ...overrides, + }; +} + describe("reconcileRuns", () => { beforeEach(() => vi.clearAllMocks()); @@ -73,6 +96,30 @@ describe("reconcileRuns", () => { expect(mockCancelRun).not.toHaveBeenCalled(); }); + it("keeps fresh claiming entry when missing from JQL snapshot but Jira still says AI", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: `claiming:${Date.now()}` }, + ]); + const issueTracker = makeIssueTracker({ + fetchTicket: vi.fn().mockResolvedValue({ + id: "id-1", + identifier: "PROJ-1", + title: "x", + description: "", + acceptanceCriteria: "", + comments: [], + labels: [], + trackerStatus: "AI", + }), + }); + const { reconcileRuns } = await import("./reconcile.js"); + + const result = await reconcileRuns(new Set(), registry, issueTracker); + + expect(result).toEqual({ cancelled: 0, cleaned: 0 }); + expect(registry.unregister).not.toHaveBeenCalled(); + }); + it("cleans completed runs that are still in AI column", async () => { const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: "run_done" }, @@ -125,6 +172,91 @@ describe("reconcileRuns", () => { expect(mockCancelRun).toHaveBeenCalledWith("PROJ-1", "run_stale", registry); }); + it("keeps running run when missing from JQL snapshot but Jira still says AI", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_live" }, + ]); + const issueTracker = makeIssueTracker({ + fetchTicket: vi.fn().mockResolvedValue({ + id: "id-1", + identifier: "PROJ-1", + title: "x", + description: "", + acceptanceCriteria: "", + comments: [], + labels: [], + trackerStatus: "AI", + }), + }); + const { reconcileRuns } = await import("./reconcile.js"); + + const result = await reconcileRuns(new Set(), registry, issueTracker); + + expect(result).toEqual({ cancelled: 0, cleaned: 0 }); + expect(mockCancelRun).not.toHaveBeenCalled(); + expect(registry.unregister).not.toHaveBeenCalled(); + }); + + it("cancels running run when ticket moved to a different project", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_live" }, + ]); + mockCancelRun.mockResolvedValue(true); + const issueTracker = makeIssueTracker({ + fetchTicket: vi.fn().mockResolvedValue({ + id: "id-1", + identifier: "OTHER-1", + projectKey: "OTHER", + title: "x", + description: "", + acceptanceCriteria: "", + comments: [], + labels: [], + trackerStatus: "AI", + }), + }); + const { reconcileRuns } = await import("./reconcile.js"); + + const result = await reconcileRuns(new Set(), registry, issueTracker); + + expect(result).toEqual({ cancelled: 1, cleaned: 0 }); + expect(mockCancelRun).toHaveBeenCalledWith("PROJ-1", "run_live", registry); + }); + + it("treats typed not-found as left column and cancels running run", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_live" }, + ]); + mockCancelRun.mockResolvedValue(true); + const issueTracker = makeIssueTracker({ + fetchTicket: vi.fn().mockRejectedValue( + new IssueTrackerNotFoundError("ticket", "PROJ-1"), + ), + }); + const { reconcileRuns } = await import("./reconcile.js"); + + const result = await reconcileRuns(new Set(), registry, issueTracker); + + expect(result).toEqual({ cancelled: 1, cleaned: 0 }); + expect(mockCancelRun).toHaveBeenCalledWith("PROJ-1", "run_live", registry); + }); + + it("keeps running run when orphan verification fails", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_live" }, + ]); + const issueTracker = makeIssueTracker({ + fetchTicket: vi.fn().mockRejectedValue(new Error("Jira API error: 500 Internal Server Error")), + }); + const { reconcileRuns } = await import("./reconcile.js"); + + const result = await reconcileRuns(new Set(), registry, issueTracker); + + expect(result).toEqual({ cancelled: 0, cleaned: 0 }); + expect(mockCancelRun).not.toHaveBeenCalled(); + expect(registry.unregister).not.toHaveBeenCalled(); + }); + it("does not unregister on a single getRun failure (strike 1 of 3)", async () => { const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: "run_ghost" }, diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index f6b5c59..cfee20d 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -1,7 +1,12 @@ import { getRun } from "workflow/api"; +import { env } from "../../env.js"; import { isClaimingSentinel, getClaimTimestamp } from "./dispatch.js"; import { cancelRun } from "./cancel-run.js"; import { logger } from "./logger.js"; +import { + IssueTrackerNotFoundError, + type IssueTrackerAdapter, +} from "../adapters/issue-tracker/types.js"; import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); @@ -19,6 +24,7 @@ const UNREACHABLE_STRIKES_LIMIT = 3; export async function reconcileRuns( aiColumnTickets: Set, runRegistry: RunRegistryAdapter, + issueTracker?: IssueTrackerAdapter, ): Promise<{ cancelled: number; cleaned: number }> { const activeRuns = await runRegistry.listAll(); let cancelled = 0; @@ -31,6 +37,7 @@ export async function reconcileRuns( runId, aiColumnTickets, runRegistry, + issueTracker, ); cancelled += result.cancelled; cleaned += result.cleaned; @@ -42,6 +49,8 @@ export async function reconcileRuns( if (ticketStillInAiColumn) { cleaned += await cleanFinishedRun(ticketKey, runId, runRegistry); } else { + const leftAiColumn = await verifyTicketLeftAiColumn(ticketKey, issueTracker); + if (!leftAiColumn) continue; await cancelRun(ticketKey, runId, runRegistry); logger.info({ ticketKey, runId }, "reconcile_cancelled_orphaned_run"); cancelled++; @@ -60,11 +69,69 @@ export async function reconcileRuns( return { cancelled, cleaned }; } +async function verifyTicketLeftAiColumn( + ticketKey: string, + issueTracker?: IssueTrackerAdapter, +): Promise { + if (!issueTracker) return true; + + try { + const ticket = await issueTracker.fetchTicket(ticketKey); + const ticketStatus = ticket.trackerStatus.trim().toLowerCase(); + const expectedStatus = env.COLUMN_AI.trim().toLowerCase(); + const ticketProjectKey = resolveTicketProjectKey(ticket); + const expectedProjectKey = env.JIRA_PROJECT_KEY.trim().toUpperCase(); + const stillInExpectedAiColumn = + ticketStatus === expectedStatus && ticketProjectKey === expectedProjectKey; + + if (stillInExpectedAiColumn) { + logger.info( + { ticketKey, status: ticket.trackerStatus, projectKey: ticketProjectKey }, + "reconcile_kept_run_missing_from_poll_snapshot", + ); + return false; + } + + return true; + } catch (err) { + if (err instanceof IssueTrackerNotFoundError || getErrorCode(err) === "NOT_FOUND") { + return true; + } + logger.warn( + { ticketKey, error: (err as Error).message }, + "reconcile_orphan_verification_failed", + ); + return false; + } +} + +function resolveTicketProjectKey(ticket: { + projectKey?: string; + identifier: string; +}): string | null { + const direct = ticket.projectKey?.trim(); + if (direct) return direct.toUpperCase(); + + const identifier = ticket.identifier?.trim(); + if (!identifier) return null; + + const dashIndex = identifier.indexOf("-"); + if (dashIndex <= 0) return null; + return identifier.slice(0, dashIndex).toUpperCase(); +} + +function getErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== "object") return undefined; + const maybeCode = (err as { code?: unknown }).code; + return typeof maybeCode === "string" ? maybeCode : undefined; +} + async function reconcileInflightClaim( ticketKey: string, runId: string, aiColumnTickets: Set, runRegistry: RunRegistryAdapter, + issueTracker?: IssueTrackerAdapter, ): Promise<{ cancelled: number; cleaned: number }> { const claimAge = Date.now() - getClaimTimestamp(runId); const claimIsStale = claimAge > STALE_CLAIM_MS; @@ -77,6 +144,8 @@ async function reconcileInflightClaim( } if (ticketLeftAiColumn) { + const leftAiColumn = await verifyTicketLeftAiColumn(ticketKey, issueTracker); + if (!leftAiColumn) return { cancelled: 0, cleaned: 0 }; await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId }, "reconcile_cancelled_inflight_claim"); return { cancelled: 1, cleaned: 0 }; diff --git a/src/routes/cron/poll.get.ts b/src/routes/cron/poll.get.ts index 2621c33..f77cbc2 100644 --- a/src/routes/cron/poll.get.ts +++ b/src/routes/cron/poll.get.ts @@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => { const { cancelled, cleaned } = await reconcileRuns( new Set(ticketKeys), adapters.runRegistry, + adapters.issueTracker, ); return { @@ -35,10 +36,23 @@ function verifyCronAuth(authHeader: string | undefined): void { async function discoverAiColumnTickets( adapters: ReturnType, ): Promise { - const jql = `project = ${env.JIRA_PROJECT_KEY} AND status = "${env.COLUMN_AI}"`; + const jql = `project = "${env.JIRA_PROJECT_KEY}" AND status = "${env.COLUMN_AI}"`; const ticketKeys = await adapters.issueTracker.searchTickets(jql); - logger.info({ ticketCount: ticketKeys.length }, "poll_discovered_tickets"); - return ticketKeys; + const normalizedKeys = normalizeTicketKeys(ticketKeys); + + if (normalizedKeys.length !== ticketKeys.length) { + logger.warn( + { + discovered: ticketKeys.length, + valid: normalizedKeys.length, + expectedProjectKey: env.JIRA_PROJECT_KEY, + }, + "poll_discarded_invalid_ticket_keys", + ); + } + + logger.info({ ticketCount: normalizedKeys.length }, "poll_discovered_tickets"); + return normalizedKeys; } async function dispatchDiscoveredTickets( @@ -55,3 +69,18 @@ async function dispatchDiscoveredTickets( return started; } + +function normalizeTicketKeys(ticketKeys: string[]): string[] { + const expectedPrefix = `${env.JIRA_PROJECT_KEY.trim().toUpperCase()}-`; + const unique = new Set(); + + for (const rawKey of ticketKeys) { + const key = typeof rawKey === "string" ? rawKey.trim() : ""; + if (!key) continue; + const normalizedKey = key.toUpperCase(); + if (!normalizedKey.startsWith(expectedPrefix)) continue; + unique.add(normalizedKey); + } + + return [...unique]; +} diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index be92fe0..79978d7 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -1,8 +1,10 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { defineEventHandler, readRawBody, getHeader, createError } from "h3"; import { env } from "../../../env.js"; +import { IssueTrackerNotFoundError } from "../../adapters/issue-tracker/types.js"; import { createAdapters } from "../../lib/adapters.js"; -import { dispatchTicket } from "../../lib/dispatch.js"; +import { cancelRun } from "../../lib/cancel-run.js"; +import { dispatchTicket, isClaimingSentinel } from "../../lib/dispatch.js"; import { logger } from "../../lib/logger.js"; /** @@ -43,6 +45,45 @@ export default defineEventHandler(async (event) => { logger.info({ ticketKey }, "webhook_received"); const adapters = createAdapters(); + const ticketStatus = extractTicketStatus(body); + if (ticketStatus && !isAiColumnStatus(ticketStatus)) { + const liveTicketState = await getLiveTicketState(ticketKey, adapters.issueTracker); + if (liveTicketState.inAiColumn) { + logger.info( + { + ticketKey, + payloadStatus: ticketStatus, + liveStatus: liveTicketState.status, + liveProjectKey: liveTicketState.projectKey, + }, + "webhook_skip_cancel_live_ticket_in_ai_column", + ); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + return { + status: result.started ? "dispatched" : "skipped", + ticketKey, + reason: result.reason, + }; + } + + const cancelled = await cancelTrackedRun(ticketKey, adapters.runRegistry); + logger.info( + { + ticketKey, + payloadStatus: ticketStatus, + liveStatus: liveTicketState.status, + liveProjectKey: liveTicketState.projectKey, + cancelled, + }, + "webhook_ticket_left_ai_column", + ); + return { + status: cancelled ? "cancelled" : "ignored", + reason: "left_ai_column", + ticketKey, + }; + } + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); logger.info( @@ -116,3 +157,67 @@ function extractProjectKey(body: any): string | null { return body?.issue?.fields?.project?.key ?? null; } +function extractTicketStatus(body: any): string | null { + return body?.issue?.fields?.status?.name ?? null; +} + +function isAiColumnStatus(status: string): boolean { + return status.trim().toLowerCase() === env.COLUMN_AI.trim().toLowerCase(); +} + +async function cancelTrackedRun( + ticketKey: string, + runRegistry: ReturnType["runRegistry"], +): Promise { + const trackedRunId = await runRegistry.getRunId(ticketKey); + if (!trackedRunId) return false; + + if (isClaimingSentinel(trackedRunId)) { + await runRegistry.unregister(ticketKey).catch(() => {}); + return true; + } + + return cancelRun(ticketKey, trackedRunId, runRegistry); +} + +async function getLiveTicketState( + ticketKey: string, + issueTracker: ReturnType["issueTracker"], +): Promise<{ inAiColumn: boolean; status: string | null; projectKey: string | null }> { + try { + const liveTicket = await issueTracker.fetchTicket(ticketKey); + const status = liveTicket.trackerStatus; + const projectKey = liveTicket.projectKey ?? extractProjectKeyFromIdentifier(liveTicket.identifier); + const inExpectedProject = + projectKey != null && + projectKey.trim().toUpperCase() === env.JIRA_PROJECT_KEY.trim().toUpperCase(); + return { + inAiColumn: isAiColumnStatus(status) && inExpectedProject, + status, + projectKey, + }; + } catch (err) { + if (err instanceof IssueTrackerNotFoundError || getErrorCode(err) === "NOT_FOUND") { + return { inAiColumn: false, status: null, projectKey: null }; + } + logger.warn( + { ticketKey, error: (err as Error).message }, + "webhook_live_ticket_state_check_failed", + ); + return { inAiColumn: true, status: null, projectKey: null }; + } +} + +function extractProjectKeyFromIdentifier(identifier: string): string | null { + const trimmed = identifier.trim(); + if (!trimmed) return null; + const dashIndex = trimmed.indexOf("-"); + if (dashIndex <= 0) return null; + return trimmed.slice(0, dashIndex).toUpperCase(); +} + +function getErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== "object") return undefined; + const maybeCode = (err as { code?: unknown }).code; + return typeof maybeCode === "string" ? maybeCode : undefined; +} From 947b1a8a10ae47ba723040674cfa1310e1ed3422 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 08:55:15 +0200 Subject: [PATCH 26/71] feat: add cancel notification --- CLAUDE.md | 70 ++++++++++++++++++++++++++++++++ src/lib/reconcile.test.ts | 13 ++++++ src/lib/reconcile.ts | 30 ++++++++++++++ src/routes/cron/poll.get.ts | 9 ++++ src/routes/webhooks/jira.post.ts | 5 +++ 5 files changed, 127 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..66cd1ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index 0dbb9eb..6980645 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -172,6 +172,19 @@ describe("reconcileRuns", () => { expect(mockCancelRun).toHaveBeenCalledWith("PROJ-1", "run_stale", registry); }); + it("emits cancel callback when run is cancelled in reconcile", async () => { + const registry = makeRegistry([ + { ticketKey: "PROJ-1", runId: "run_stale" }, + ]); + mockCancelRun.mockResolvedValue(true); + const onTicketCancelled = vi.fn().mockResolvedValue(undefined); + const { reconcileRuns } = await import("./reconcile.js"); + + await reconcileRuns(new Set(), registry, undefined, onTicketCancelled); + + expect(onTicketCancelled).toHaveBeenCalledWith("PROJ-1", "orphaned_run"); + }); + it("keeps running run when missing from JQL snapshot but Jira still says AI", async () => { const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: "run_live" }, diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index cfee20d..9638bde 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -25,6 +25,10 @@ export async function reconcileRuns( aiColumnTickets: Set, runRegistry: RunRegistryAdapter, issueTracker?: IssueTrackerAdapter, + onTicketCancelled?: ( + ticketKey: string, + reason: "orphaned_run" | "inflight_claim", + ) => Promise | void, ): Promise<{ cancelled: number; cleaned: number }> { const activeRuns = await runRegistry.listAll(); let cancelled = 0; @@ -38,6 +42,7 @@ export async function reconcileRuns( aiColumnTickets, runRegistry, issueTracker, + onTicketCancelled, ); cancelled += result.cancelled; cleaned += result.cleaned; @@ -53,6 +58,7 @@ export async function reconcileRuns( if (!leftAiColumn) continue; await cancelRun(ticketKey, runId, runRegistry); logger.info({ ticketKey, runId }, "reconcile_cancelled_orphaned_run"); + await notifyTicketCancelled(ticketKey, "orphaned_run", onTicketCancelled); cancelled++; } } @@ -132,6 +138,10 @@ async function reconcileInflightClaim( aiColumnTickets: Set, runRegistry: RunRegistryAdapter, issueTracker?: IssueTrackerAdapter, + onTicketCancelled?: ( + ticketKey: string, + reason: "orphaned_run" | "inflight_claim", + ) => Promise | void, ): Promise<{ cancelled: number; cleaned: number }> { const claimAge = Date.now() - getClaimTimestamp(runId); const claimIsStale = claimAge > STALE_CLAIM_MS; @@ -148,12 +158,32 @@ async function reconcileInflightClaim( if (!leftAiColumn) return { cancelled: 0, cleaned: 0 }; await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId }, "reconcile_cancelled_inflight_claim"); + await notifyTicketCancelled(ticketKey, "inflight_claim", onTicketCancelled); return { cancelled: 1, cleaned: 0 }; } return { cancelled: 0, cleaned: 0 }; } +async function notifyTicketCancelled( + ticketKey: string, + reason: "orphaned_run" | "inflight_claim", + onTicketCancelled?: ( + ticketKey: string, + reason: "orphaned_run" | "inflight_claim", + ) => Promise | void, +): Promise { + if (!onTicketCancelled) return; + try { + await onTicketCancelled(ticketKey, reason); + } catch (err) { + logger.warn( + { ticketKey, reason, error: (err as Error).message }, + "reconcile_cancel_notification_failed", + ); + } +} + async function cleanFinishedRun( ticketKey: string, runId: string, diff --git a/src/routes/cron/poll.get.ts b/src/routes/cron/poll.get.ts index f77cbc2..1e46e52 100644 --- a/src/routes/cron/poll.get.ts +++ b/src/routes/cron/poll.get.ts @@ -15,6 +15,15 @@ export default defineEventHandler(async (event) => { new Set(ticketKeys), adapters.runRegistry, adapters.issueTracker, + async (ticketKey, reason) => { + const detail = + reason === "inflight_claim" + ? "claim was cleared after the ticket left AI" + : "workflow run was cancelled after the ticket left AI"; + await adapters.messaging.notify( + `Task ${ticketKey} canceled: ${detail}.`, + ); + }, ); return { diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 79978d7..9dbe03e 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -67,6 +67,11 @@ export default defineEventHandler(async (event) => { } const cancelled = await cancelTrackedRun(ticketKey, adapters.runRegistry); + if (cancelled) { + await adapters.messaging.notify( + `Task ${ticketKey} canceled: webhook confirmed ticket is outside AI column.`, + ); + } logger.info( { ticketKey, From ad0911f0b3a195740ae4e2a0131d884e043ebf40 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 09:05:20 +0200 Subject: [PATCH 27/71] fix: sandbox cancelation --- AGENTS.md | 70 +++++++++++++++++++ src/lib/cancel-run.test.ts | 12 +++- src/lib/cancel-run.ts | 2 + src/lib/dispatch.test.ts | 7 ++ src/lib/dispatch.ts | 2 + src/sandbox/stop-ticket-sandboxes.test.ts | 69 ++++++++++++++++++ src/sandbox/stop-ticket-sandboxes.ts | 85 +++++++++++++++++++++++ 7 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 src/sandbox/stop-ticket-sandboxes.test.ts create mode 100644 src/sandbox/stop-ticket-sandboxes.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66cd1ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/src/lib/cancel-run.test.ts b/src/lib/cancel-run.test.ts index 73bf7f0..18aa0a6 100644 --- a/src/lib/cancel-run.test.ts +++ b/src/lib/cancel-run.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; const mockGetRun = vi.fn(); +const mockStopTicketSandboxes = vi.fn(); vi.mock("workflow/api", () => ({ getRun: (...args: any[]) => mockGetRun(...args), })); +vi.mock("../sandbox/stop-ticket-sandboxes.js", () => ({ + stopTicketSandboxes: (...args: any[]) => mockStopTicketSandboxes(...args), +})); function makeRegistry(overrides: Partial = {}): RunRegistryAdapter { return { @@ -21,7 +25,10 @@ function makeRegistry(overrides: Partial = {}): RunRegistryA } describe("cancelRun", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockStopTicketSandboxes.mockResolvedValue(0); + }); it("cancels the run and unregisters", async () => { const mockCancel = vi.fn().mockResolvedValue(undefined); @@ -34,6 +41,7 @@ describe("cancelRun", () => { expect(result).toBe(true); expect(mockGetRun).toHaveBeenCalledWith("run_abc"); expect(mockCancel).toHaveBeenCalled(); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); }); @@ -47,6 +55,7 @@ describe("cancelRun", () => { const result = await cancelRun("PROJ-1", "run_abc", registry); expect(result).toBe(false); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); }); @@ -63,5 +72,6 @@ describe("cancelRun", () => { expect(result).toBe(false); expect(unregister).toHaveBeenCalledTimes(2); + expect(mockStopTicketSandboxes).toHaveBeenCalledTimes(2); }); }); diff --git a/src/lib/cancel-run.ts b/src/lib/cancel-run.ts index 830abdb..2c54e32 100644 --- a/src/lib/cancel-run.ts +++ b/src/lib/cancel-run.ts @@ -1,6 +1,7 @@ import { getRun } from "workflow/api"; import { logger } from "./logger.js"; import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; +import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; /** * Cancel a workflow run and unregister it from the registry. @@ -24,6 +25,7 @@ export async function cancelRun( ); } + await stopTicketSandboxes(ticketKey).catch(() => {}); await runRegistry.unregister(ticketKey).catch(() => {}); return cancelled; } diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 9e47d66..e025e5c 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -21,11 +21,15 @@ vi.mock("../workflows/agent.js", () => ({ })); const mockSandboxList = vi.fn(); +const mockStopTicketSandboxes = vi.fn(); vi.mock("@vercel/sandbox", () => ({ Sandbox: { list: (...args: any[]) => mockSandboxList(...args), }, })); +vi.mock("../sandbox/stop-ticket-sandboxes.js", () => ({ + stopTicketSandboxes: (...args: any[]) => mockStopTicketSandboxes(...args), +})); function makeTicket(overrides: Partial = {}): TicketContent { return { @@ -103,6 +107,7 @@ describe("dispatchTicket", () => { json: { sandboxes: [] }, }); mockStart.mockResolvedValue({ runId: "run_123" }); + mockStopTicketSandboxes.mockResolvedValue(0); }); it("dispatches agentWorkflow for a ticket in configured project + AI column", async () => { @@ -205,6 +210,7 @@ describe("dispatchTicket", () => { expect(mockStart).toHaveBeenCalled(); expect(mockGetRun).toHaveBeenCalledWith("run_123"); expect(mockCancel).toHaveBeenCalled(); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-42"); expect(adapters.runRegistry.register).not.toHaveBeenCalled(); }); @@ -272,6 +278,7 @@ describe("failed-ticket safeguard full loop", () => { vi.clearAllMocks(); mockSandboxList.mockResolvedValue({ json: { sandboxes: [] } }); mockStart.mockResolvedValue({ runId: "run_123" }); + mockStopTicketSandboxes.mockResolvedValue(0); }); it("mark → skip → clear → redispatch", async () => { diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index c582ed9..bed33ee 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -3,6 +3,7 @@ import { env } from "../../env.js"; import { agentWorkflow } from "../workflows/agent.js"; import { logger } from "./logger.js"; import type { Adapters } from "./adapters.js"; +import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; const CLAIMING_PREFIX = "claiming:"; @@ -140,6 +141,7 @@ async function abortWorkflow(runId: string, ticketKey: string): Promise { const run = getRun(runId); await run.cancel(); } catch {} + await stopTicketSandboxes(ticketKey).catch(() => {}); } function extractProjectKey(ticketIdentifier: string): string | null { diff --git a/src/sandbox/stop-ticket-sandboxes.test.ts b/src/sandbox/stop-ticket-sandboxes.test.ts new file mode 100644 index 0000000..8c2a90c --- /dev/null +++ b/src/sandbox/stop-ticket-sandboxes.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockList = vi.fn(); +const mockGet = vi.fn(); + +vi.mock("@vercel/sandbox", () => ({ + Sandbox: { + list: (...args: any[]) => mockList(...args), + get: (...args: any[]) => mockGet(...args), + }, +})); + +vi.mock("./credentials.js", () => ({ + getSandboxCredentials: vi.fn(() => ({})), +})); + +function makeSandbox(branch: string, status: "running" | "stopped" = "running") { + return { + status, + runCommand: vi.fn().mockResolvedValue({ + exitCode: 0, + stdout: async () => branch, + }), + stop: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("stopTicketSandboxes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("stops running sandboxes on the ticket branch", async () => { + const matching = makeSandbox("blazebot/proj-42"); + const other = makeSandbox("blazebot/proj-99"); + + mockList.mockResolvedValue({ + json: { + sandboxes: [ + { id: "sbx-1", status: "running" }, + { id: "sbx-2", status: "running" }, + ], + }, + }); + + mockGet.mockImplementation(async ({ sandboxId }: { sandboxId: string }) => { + if (sandboxId === "sbx-1") return matching; + if (sandboxId === "sbx-2") return other; + throw new Error(`unexpected sandbox id: ${sandboxId}`); + }); + + const { stopTicketSandboxes } = await import("./stop-ticket-sandboxes.js"); + const stopped = await stopTicketSandboxes("PROJ-42"); + + expect(stopped).toBe(1); + expect(matching.stop).toHaveBeenCalledTimes(1); + expect(other.stop).not.toHaveBeenCalled(); + }); + + it("returns 0 when sandbox listing fails", async () => { + mockList.mockRejectedValue(new Error("sandbox api down")); + + const { stopTicketSandboxes } = await import("./stop-ticket-sandboxes.js"); + const stopped = await stopTicketSandboxes("PROJ-42"); + + expect(stopped).toBe(0); + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/sandbox/stop-ticket-sandboxes.ts b/src/sandbox/stop-ticket-sandboxes.ts new file mode 100644 index 0000000..25e0226 --- /dev/null +++ b/src/sandbox/stop-ticket-sandboxes.ts @@ -0,0 +1,85 @@ +import { logger } from "../lib/logger.js"; +import { getSandboxCredentials } from "./credentials.js"; + +const BRANCH_PREFIX = "blazebot/"; + +/** + * Best-effort cleanup for leaked sandboxes after ticket cancellation. + * Finds running sandboxes whose checked-out branch matches the ticket branch + * and requests stop on each match. + */ +export async function stopTicketSandboxes(ticketKey: string): Promise { + const normalizedTicket = ticketKey.trim().toLowerCase(); + if (!normalizedTicket) return 0; + + const expectedBranch = `${BRANCH_PREFIX}${normalizedTicket}`; + + try { + const { Sandbox } = await import("@vercel/sandbox"); + const credentials = getSandboxCredentials(); + const { json } = await Sandbox.list({ ...credentials, limit: 100 }); + const running = json.sandboxes.filter((sandbox) => sandbox.status === "running"); + + let stopped = 0; + for (const entry of running) { + try { + const sandbox = await Sandbox.get({ + ...credentials, + sandboxId: entry.id, + }); + if (sandbox.status !== "running") continue; + + const branch = await getSandboxBranch(sandbox); + if (branch !== expectedBranch) continue; + + await sandbox.stop(); + stopped++; + } catch (err) { + logger.warn( + { + ticketKey, + sandboxId: entry.id, + error: (err as Error).message, + }, + "cancel_run_sandbox_stop_failed", + ); + } + } + + if (stopped > 0) { + logger.info( + { ticketKey, expectedBranch, stopped }, + "cancel_run_stopped_ticket_sandboxes", + ); + } + return stopped; + } catch (err) { + logger.warn( + { ticketKey, expectedBranch, error: (err as Error).message }, + "cancel_run_sandbox_discovery_failed", + ); + return 0; + } +} + +async function getSandboxBranch(sandbox: { + runCommand: ( + params: { + cmd: string; + args: string[]; + cwd: string; + }, + ) => Promise<{ exitCode: number; stdout: () => Promise }>; +}): Promise { + try { + const result = await sandbox.runCommand({ + cmd: "git", + args: ["rev-parse", "--abbrev-ref", "HEAD"], + cwd: "/vercel/sandbox", + }); + if (result.exitCode !== 0) return null; + return (await result.stdout()).trim(); + } catch { + return null; + } +} From c78a9cc48cb996a1b72735b80fae50589b528ea9 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 09:17:51 +0200 Subject: [PATCH 28/71] fix: clarification flow --- src/sandbox/agent-runner.test.ts | 20 ++++++++++++++++++++ src/sandbox/agent-runner.ts | 16 ++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/sandbox/agent-runner.test.ts b/src/sandbox/agent-runner.test.ts index 9a0f395..a3d8eea 100644 --- a/src/sandbox/agent-runner.test.ts +++ b/src/sandbox/agent-runner.test.ts @@ -160,6 +160,26 @@ describe("parseResearchStatus", () => { const { status } = parseResearchStatus(raw); expect(status).toBe("completed"); }); + + it("handles leading blank lines before STATUS", () => { + const raw = "\n\nSTATUS: clarification_needed\n\n1. Which provider?"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("clarification_needed"); + expect(body).toContain("Which provider?"); + }); + + it("normalizes uppercase status values", () => { + const raw = "STATUS: CLARIFICATION_NEEDED\n\n1. Which provider?"; + const { status } = parseResearchStatus(raw); + expect(status).toBe("clarification_needed"); + }); + + it("extracts STATUS from fenced output", () => { + const raw = "```markdown\nSTATUS: clarification_needed\n\n1. Which provider?\n```"; + const { status, body } = parseResearchStatus(raw); + expect(status).toBe("clarification_needed"); + expect(body).toContain("Which provider?"); + }); }); describe("parseReviewOutput", () => { diff --git a/src/sandbox/agent-runner.ts b/src/sandbox/agent-runner.ts index 11005c9..e53568e 100644 --- a/src/sandbox/agent-runner.ts +++ b/src/sandbox/agent-runner.ts @@ -117,12 +117,16 @@ const VALID_RESEARCH_STATUSES: ResearchStatus[] = ["completed", "clarification_n export function parseResearchStatus(raw: string): ResearchResult { const lines = raw.split("\n"); - const firstLine = lines[0]?.trim() ?? ""; - const match = firstLine.match(/^STATUS:\s*(\S+)/i); - - if (match && VALID_RESEARCH_STATUSES.includes(match[1] as ResearchStatus)) { - const body = lines.slice(1).join("\n").trim(); - return { status: match[1] as ResearchStatus, body }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim() ?? ""; + const match = line.match(/^STATUS:\s*([a-z_]+)/i); + if (!match) continue; + + const status = match[1].toLowerCase() as ResearchStatus; + if (VALID_RESEARCH_STATUSES.includes(status)) { + const body = lines.slice(i + 1).join("\n").trim(); + return { status, body }; + } } return { status: "failed", body: raw }; From c18c0d671b65e72c3efdab6587e00e1df843f0b2 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 09:27:21 +0200 Subject: [PATCH 29/71] feat: tune resaerch prompt --- src/lib/prompts.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index e954dc1..6a7b9f3 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -50,9 +50,21 @@ Return \`STATUS: clarification_needed\` if: - Contradictory requirements - Multiple valid interpretations - Missing design/UX details for UI work +- The ticket uses subjective/vague references (for example "favorite page", "do the thing", "fix it") without an explicit file/route/component target + +If the ticket requires assumptions to pick a target or behavior, you MUST ask clarification instead of guessing from repository structure. When you need clarification, list your questions as numbered lines after the STATUS line. Batch ALL questions — never return with just one. +## Mandatory Clarity Gate (Before Choosing STATUS: completed) + +You MUST answer YES to ALL checks below before returning \`STATUS: completed\`: +1. Is the exact implementation target explicit (file/path/component/endpoint), without relying on assumptions? +2. Is the expected behavior explicit enough to implement and verify? +3. Is "done" objectively checkable from ticket + comments + acceptance criteria? + +If any answer is NO, return \`STATUS: clarification_needed\` with precise numbered questions. + ## Constraints - **NO coding** — do not write implementation code From 0c634248e6a70c753738e22621a7ddec0a3993d4 Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:58:00 +0200 Subject: [PATCH 30/71] feat: attachemnt support (#50) * feat: attachemnt support * fix: attachement fetch * fix: ci fail * feat: enhance JiraAdapter and dispatch logic - Added tests for sanitizing malformed attachment sizes in JiraAdapter. - Improved handling of cross-origin requests by omitting Authorization headers. - Enhanced redirect handling in JiraAdapter to support multiple redirects. - Updated dispatchTicket function to paginate sandbox list and handle capacity checks more robustly. - Added pagination support in sandbox list responses to improve efficiency. * fix: ci * feat: enhance JiraAdapter attachment handling and improve error management - Added test to omit contentUrl when Jira does not provide attachment content. - Improved attachment handling in JiraAdapter to ensure contentUrl is undefined when absent. - Enhanced error handling for missing Location headers during redirects in attachment downloads. - Updated fetchAttachmentsWithRetry to skip attachments without content URLs and log appropriate messages. --- .env.example | 6 + .../2026-04-13-jira-ticket-attachments.md | 1841 +++++++++++++++++ ...26-04-13-jira-ticket-attachments-design.md | 232 +++ env.ts | 6 + src/adapters/issue-tracker/jira.test.ts | 324 +++ src/adapters/issue-tracker/jira.ts | 77 +- src/adapters/issue-tracker/types.ts | 14 + src/lib/dispatch.test.ts | 71 +- src/lib/dispatch.ts | 34 +- src/routes/cron/poll.get.ts | 11 +- src/sandbox/attachments.integration.test.ts | 58 + src/sandbox/attachments.test.ts | 363 ++++ src/sandbox/attachments.ts | 238 +++ src/sandbox/context.test.ts | 277 +++ src/sandbox/context.ts | 33 +- src/workflows/agent.ts | 103 + 16 files changed, 3670 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-13-jira-ticket-attachments.md create mode 100644 docs/superpowers/specs/2026-04-13-jira-ticket-attachments-design.md create mode 100644 src/sandbox/attachments.integration.test.ts create mode 100644 src/sandbox/attachments.test.ts create mode 100644 src/sandbox/attachments.ts diff --git a/.env.example b/.env.example index 818f941..8d24802 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,12 @@ COMMIT_EMAIL=ai-workflow@blazity.com MAX_CONCURRENT_AGENTS=3 JOB_TIMEOUT_MS=1800000 +# Attachments (all optional — defaults shown) +# ATTACHMENT_MAX_FILE_SIZE_MB=25 +# ATTACHMENT_MAX_TOTAL_SIZE_MB=100 +# ATTACHMENT_MAX_COUNT=20 +# ATTACHMENT_DOWNLOAD_TIMEOUT_MS=30000 + # Polling POLL_INTERVAL_MS=300000 diff --git a/docs/superpowers/plans/2026-04-13-jira-ticket-attachments.md b/docs/superpowers/plans/2026-04-13-jira-ticket-attachments.md new file mode 100644 index 0000000..89d2b30 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-jira-ticket-attachments.md @@ -0,0 +1,1841 @@ +# Jira Ticket Attachments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Jira ticket file attachments available to the agent inside the sandbox at `/tmp/attachments/` and advertise them via an index in `requirements.md`, so the agent can read mockups, spec PDFs, sample fixtures, and screenshots during research, implementation, and review. + +**Architecture:** Extend `JiraAdapter` to surface attachment metadata and download bytes. Add a pure helper module `src/sandbox/attachments.ts` containing the retry loop, filename sanitizer, index formatter, and byte formatter. Wire two new workflow steps (`fetchAttachments`, `writeAttachments`) into `agentWorkflow` between `provisionSandbox` and Phase 1. Thread a `DownloadedAttachment[]` parameter through the four `assembleXContext` functions so an `## Attachments` section is rendered once per phase. Safety caps and timeouts come from new env vars. + +**Tech Stack:** TypeScript, Vitest, `@vercel/sandbox` (writeFiles), `@t3-oss/env-core` + Zod (env), pino (logging), native `fetch` + `AbortSignal` (downloads). + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `src/adapters/issue-tracker/types.ts` | **Modify** | Add `TicketAttachment` interface; add `attachments: TicketAttachment[]` to `TicketContent`. | +| `src/adapters/issue-tracker/jira.ts` | **Modify** | Request `attachment` field in `fetchTicket`; map `data.fields.attachment` to `TicketAttachment[]`; add `downloadAttachment(url)` with manual-redirect auth-stripping and timeout. | +| `src/adapters/issue-tracker/jira.test.ts` | **Modify** | Extend tests: attachment parsing (present + absent), `downloadAttachment` follows one 302 and drops `Authorization` on the redirect. | +| `src/sandbox/attachments.ts` | **Create** | Pure helpers: `sanitizeFilename`, `formatBytes`, `formatAttachmentsIndex`, and the retry/caps loop `fetchAttachmentsWithRetry`. Exports `DownloadedAttachment` type. | +| `src/sandbox/attachments.test.ts` | **Create** | Unit tests for all four exports. | +| `src/sandbox/context.ts` | **Modify** | Accept optional `attachments` on all four `assembleXContext` functions; inject `## Attachments` section after the Ticket header block. | +| `src/sandbox/context.test.ts` | **Modify** | Add cases for attachment index rendering (present / empty / all-failed / mixed). | +| `src/workflows/agent.ts` | **Modify** | Two new `"use step"` functions (`fetchAttachments` before `provisionSandbox`, `writeAttachments` as the first action inside the sandbox `try {}`); forward the result into all four `assembleXContext` calls. | +| `env.ts` | **Modify** | Four new server vars: `ATTACHMENT_MAX_FILE_SIZE_MB`, `ATTACHMENT_MAX_TOTAL_SIZE_MB`, `ATTACHMENT_MAX_COUNT`, `ATTACHMENT_DOWNLOAD_TIMEOUT_MS`. | + +No changes to VCS adapters, run registry, Slack messaging, or sandbox manager. + +--- + +## Shared Types (referenced by multiple tasks) + +Defined in Task 2 (`TicketAttachment`) and Task 4 (`DownloadedAttachment`). Reproduced here so steps that use them later don't have to repeat the shape: + +```ts +// src/adapters/issue-tracker/types.ts +export interface TicketAttachment { + id: string; + filename: string; + mimeType: string; + size: number; + contentUrl: string; +} +``` + +```ts +// src/sandbox/attachments.ts +export interface DownloadedAttachment { + filename: string; // sanitized, collision-resolved + originalFilename: string; + mimeType: string; + size: number; + content?: Buffer; // present only on success + failed?: { reason: string; attempts: number }; // present only on failure +} + +export interface AttachmentCaps { + maxFileSizeBytes: number; + maxTotalSizeBytes: number; + maxCount: number; + downloadTimeoutMs: number; +} +``` + +--- + +## Task 1: Add safety-cap env vars + +**Files:** +- Modify: `env.ts` + +- [ ] **Step 1: Add the four new vars to the `server` block** + +In `env.ts`, inside the `server: { ... }` object in `createEnv(...)`, add the following entries (place them after the existing `Sandbox` group, before `POLL_INTERVAL_MS`): + +```ts + // Attachments + ATTACHMENT_MAX_FILE_SIZE_MB: z.coerce.number().int().positive().default(25), + ATTACHMENT_MAX_TOTAL_SIZE_MB: z.coerce.number().int().positive().default(100), + ATTACHMENT_MAX_COUNT: z.coerce.number().int().positive().default(20), + ATTACHMENT_DOWNLOAD_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), +``` + +- [ ] **Step 2: Verify typecheck passes** + +Run: `npm run typecheck` +Expected: exits 0 with no errors. + +- [ ] **Step 3: Verify env loads with defaults** + +Run: +```bash +node -e "import('./env.ts').then(m => console.log({ + file: m.env.ATTACHMENT_MAX_FILE_SIZE_MB, + total: m.env.ATTACHMENT_MAX_TOTAL_SIZE_MB, + count: m.env.ATTACHMENT_MAX_COUNT, + timeout: m.env.ATTACHMENT_DOWNLOAD_TIMEOUT_MS, +}))" +``` +Expected: prints `{ file: 25, total: 100, count: 20, timeout: 30000 }` (or the overrides if already set in the environment). + +--- + +## Task 2: Add `TicketAttachment` type and extend `TicketContent` + +**Files:** +- Modify: `src/adapters/issue-tracker/types.ts` + +Two targeted edits — add the `TicketAttachment` interface after `TicketComment`, and add a new `attachments` field to `TicketContent`. Do **not** replace the whole file; targeted edits are safer against concurrent changes. + +- [ ] **Step 1: Add `attachments: TicketAttachment[]` to `TicketContent`** + +In `src/adapters/issue-tracker/types.ts`, in the `TicketContent` interface, add a final field immediately after `trackerStatus: string;`: + +```ts + attachments: TicketAttachment[]; +``` + +The interface now reads (for reference): + +```ts +export interface TicketContent { + id: string; + identifier: string; + title: string; + description: string; + acceptanceCriteria: string; + comments: TicketComment[]; + labels: string[]; + trackerStatus: string; + attachments: TicketAttachment[]; +} +``` + +- [ ] **Step 2: Add the `TicketAttachment` interface** + +In the same file, insert the following immediately after the `TicketComment` interface (and before `IssueTrackerAdapter`): + +```ts +export interface TicketAttachment { + id: string; + filename: string; + mimeType: string; + size: number; + contentUrl: string; +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` +Expected: FAIL — the existing `JiraAdapter.fetchTicket` does not populate `attachments`, so TypeScript will flag it. This is expected. Task 3 fixes it. + +--- + +## Task 3: Parse attachment metadata in `JiraAdapter.fetchTicket` + +**Files:** +- Modify: `src/adapters/issue-tracker/jira.ts:41-61` +- Modify: `src/adapters/issue-tracker/jira.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add this `describe` block inside `describe("JiraAdapter", () => { ... })` in `src/adapters/issue-tracker/jira.test.ts`, immediately after the existing `describe("fetchTicket", () => { ... })`: + +```ts + describe("fetchTicket attachments", () => { + it("parses attachment metadata into TicketAttachment[]", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10001", + key: "PROJ-1", + fields: { + summary: "Has attachments", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [ + { + id: "att-1", + filename: "mockup.png", + mimeType: "image/png", + size: 348192, + content: "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + }, + { + id: "att-2", + filename: "spec.pdf", + mimeType: "application/pdf", + size: 52100, + content: "https://test.atlassian.net/secure/attachment/att-2/spec.pdf", + }, + ], + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10001"); + + expect(ticket.attachments).toHaveLength(2); + expect(ticket.attachments[0]).toEqual({ + id: "att-1", + filename: "mockup.png", + mimeType: "image/png", + size: 348192, + contentUrl: "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + }); + }); + + it("returns empty attachments array when field is absent", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10002", + key: "PROJ-2", + fields: { + summary: "No attachments", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + // attachment field intentionally omitted + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10002"); + expect(ticket.attachments).toEqual([]); + }); + + it("requests attachment field in the fields query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10003", + key: "PROJ-3", + fields: { + summary: "x", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [], + }, + }), + }); + + const adapter = jiraAdapter(); + await adapter.fetchTicket("10003"); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain("fields="); + expect(url).toContain("attachment"); + }); + }); +``` + +Also extend the existing "returns normalized ticket content" test so it doesn't break — the `TicketContent` type now requires `attachments`. Add `attachment: []` to the `fields` object in that test's mock response, and add: + +```ts + expect(ticket.attachments).toEqual([]); +``` + +before the closing `});` of the `it` block. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/adapters/issue-tracker/jira.test.ts` +Expected: FAIL — the new tests report `ticket.attachments` is `undefined`. + +- [ ] **Step 3: Update `fetchTicket` in `jira.ts`** + +In `src/adapters/issue-tracker/jira.ts`, replace the `fetchTicket` method (currently at lines 41-61) with: + +```ts + async fetchTicket(id: string): Promise { + const data = await this.request( + `/rest/api/3/issue/${id}?fields=summary,description,comment,labels,status,attachment`, + ); + return { + id: data.id, + identifier: data.key, + title: data.fields.summary ?? "", + description: extractAdfText(data.fields.description), + acceptanceCriteria: extractAcceptanceCriteria(data.fields.description), + comments: (data.fields.comment?.comments ?? []).map( + (c: any): TicketComment => ({ + author: c.author?.displayName ?? "unknown", + body: extractAdfText(c.body), + createdAt: c.created, + }), + ), + labels: data.fields.labels ?? [], + trackerStatus: data.fields.status?.name ?? "", + attachments: (data.fields.attachment ?? []).map( + (a: any): TicketAttachment => ({ + id: String(a.id), + filename: a.filename ?? "", + mimeType: a.mimeType ?? "application/octet-stream", + size: Number(a.size ?? 0), + contentUrl: a.content ?? "", + }), + ), + }; + } +``` + +Update the existing type import at the top of the file to: + +```ts +import type { IssueTrackerAdapter, TicketContent, TicketComment, TicketAttachment } from "./types.js"; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/adapters/issue-tracker/jira.test.ts` +Expected: PASS — all attachment tests and the updated existing test pass. + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +--- + +## Task 4: Add `downloadAttachment` to `JiraAdapter` + +**Files:** +- Modify: `src/adapters/issue-tracker/jira.ts` +- Modify: `src/adapters/issue-tracker/jira.test.ts` + +This step adds a raw-bytes downloader with two quirks: +1. Atlassian's attachment URL returns a 302 to a signed CDN URL. If we re-send `Authorization: Basic ...` on the redirect, the CDN rejects the request because its signed URL is the auth. We must follow one redirect **manually** with a fresh request that omits the `Authorization` header. +2. We bound the whole operation with a 30s (configurable) timeout via `AbortSignal.timeout(ms)`. + +- [ ] **Step 1: Write the failing tests** + +Add this `describe` block in `src/adapters/issue-tracker/jira.test.ts`, after the `describe("fetchTicket attachments", ...)` block: + +```ts + describe("downloadAttachment", () => { + it("follows one 302 redirect without Authorization header", async () => { + const redirectUrl = "https://atlassian-cdn.example/signed?x=1"; + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + headers: { get: (n: string) => (n.toLowerCase() === "location" ? redirectUrl : null) }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + }); + + const adapter = jiraAdapter(); + const buf = await adapter.downloadAttachment( + "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + ); + + expect(buf).toBeInstanceOf(Buffer); + expect(buf.length).toBe(4); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call: to Jira, with Authorization. + const firstInit = mockFetch.mock.calls[0][1] as RequestInit; + expect((firstInit.headers as Record).Authorization).toMatch(/^Basic /); + expect(firstInit.redirect).toBe("manual"); + + // Second call: to the CDN, WITHOUT Authorization. + const secondInit = mockFetch.mock.calls[1][1] as RequestInit; + const secondHeaders = (secondInit.headers ?? {}) as Record; + expect(secondHeaders.Authorization).toBeUndefined(); + expect(mockFetch.mock.calls[1][0]).toBe(redirectUrl); + }); + + it("returns bytes directly on 200 (no redirect)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + }); + + const adapter = jiraAdapter(); + const buf = await adapter.downloadAttachment( + "https://test.atlassian.net/secure/attachment/att-1/data.bin", + ); + expect(Array.from(buf)).toEqual([1, 2, 3]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws on non-2xx, non-302 responses", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: { get: () => null }, + }); + + const adapter = jiraAdapter(); + await expect( + adapter.downloadAttachment("https://test.atlassian.net/secure/attachment/att-1/x"), + ).rejects.toThrow(/500/); + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/adapters/issue-tracker/jira.test.ts` +Expected: FAIL — `adapter.downloadAttachment is not a function`. + +- [ ] **Step 3: Implement `downloadAttachment`** + +In `src/adapters/issue-tracker/jira.ts`, add the following method to the `JiraAdapter` class (place it right after `postComment`, before `searchTickets`): + +```ts + async downloadAttachment( + url: string, + opts: { timeoutMs?: number } = {}, + ): Promise { + const timeoutMs = opts.timeoutMs ?? 30_000; + const signal = AbortSignal.timeout(timeoutMs); + + // First request: authenticated, manual redirect handling. + const first = await fetch(url, { + method: "GET", + headers: { Authorization: this.authHeader }, + redirect: "manual", + signal, + }); + + if (first.status === 302 || first.status === 301) { + const location = first.headers.get("location"); + if (!location) { + throw new Error( + `Jira attachment redirect (${first.status}) missing Location header for ${url}`, + ); + } + // Re-fetch the signed CDN URL WITHOUT Authorization (its signature IS the auth). + // Use redirect: "follow" so CDN-internal redirects (e.g. S3 region redirects) work. + const second = await fetch(location, { + method: "GET", + redirect: "follow", + signal, + }); + if (!second.ok) { + throw new Error( + `Jira attachment CDN error: ${second.status} ${second.statusText} on ${location}`, + ); + } + return Buffer.from(await second.arrayBuffer()); + } + + if (!first.ok) { + throw new Error( + `Jira attachment error: ${first.status} ${first.statusText} on ${url}`, + ); + } + return Buffer.from(await first.arrayBuffer()); + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/adapters/issue-tracker/jira.test.ts` +Expected: PASS — all three new cases pass, existing tests still pass. + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +--- + +## Task 5: Create `src/sandbox/attachments.ts` scaffold and pure helpers + +**Files:** +- Create: `src/sandbox/attachments.ts` +- Create: `src/sandbox/attachments.test.ts` + +This task adds the pure utilities (`sanitizeFilename`, `formatBytes`, `formatAttachmentsIndex`) and the `DownloadedAttachment` / `AttachmentCaps` types. The retry loop comes in Task 6. + +- [ ] **Step 1: Write the failing tests** + +Create `src/sandbox/attachments.test.ts` with: + +```ts +import { describe, it, expect } from "vitest"; +import { + sanitizeFilename, + formatBytes, + formatAttachmentsIndex, + type DownloadedAttachment, +} from "./attachments.js"; + +describe("sanitizeFilename", () => { + it("preserves simple names", () => { + expect(sanitizeFilename("mockup.png", "att-1")).toBe("mockup.png"); + }); + + it("strips path separators", () => { + expect(sanitizeFilename("a/b/c.png", "att-1")).toBe("abc.png"); + expect(sanitizeFilename("a\\b\\c.png", "att-1")).toBe("abc.png"); + }); + + it("strips null bytes", () => { + expect(sanitizeFilename("a\u0000b.png", "att-1")).toBe("ab.png"); + }); + + it("strips leading dots (no hidden files)", () => { + expect(sanitizeFilename(".env", "att-1")).toBe("env"); + expect(sanitizeFilename("...weird", "att-1")).toBe("weird"); + }); + + it("falls back to attachment- when result is empty", () => { + expect(sanitizeFilename("", "att-9")).toBe("attachment-att-9"); + expect(sanitizeFilename("///", "att-9")).toBe("attachment-att-9"); + expect(sanitizeFilename("....", "att-9")).toBe("attachment-att-9"); + }); + + // Note: after stripping path separators and leading dots, ".pdf" becomes "pdf" + // (non-empty), so the fallback does NOT fire. This matches the spec's literal + // rules ("strip leading dots; fall back only if empty"). Documented explicitly + // so implementers don't get confused. + it("does NOT invoke fallback when stripping leaves a non-empty extension-only name", () => { + expect(sanitizeFilename(".pdf", "att-9")).toBe("pdf"); + expect(sanitizeFilename("/.png", "att-9")).toBe("png"); + }); +}); + +describe("formatBytes", () => { + it("formats bytes under 1KB as B", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + + it("formats KB with no decimals for whole numbers", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(2048)).toBe("2 KB"); + }); + + it("formats KB with one decimal for fractions", () => { + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(348_192)).toBe("340 KB"); + }); + + it("formats MB with one decimal", () => { + expect(formatBytes(1_258_291)).toBe("1.2 MB"); + expect(formatBytes(10 * 1024 * 1024)).toBe("10 MB"); + }); +}); + +describe("formatAttachmentsIndex", () => { + const ok = (filename: string, mimeType: string, size: number): DownloadedAttachment => ({ + filename, + originalFilename: filename, + mimeType, + size, + content: Buffer.from([]), + }); + const fail = ( + filename: string, + reason: string, + attempts = 1, + ): DownloadedAttachment => ({ + filename, + originalFilename: filename, + mimeType: "application/octet-stream", + size: 0, + failed: { reason, attempts }, + }); + + it("returns empty string when no attachments", () => { + expect(formatAttachmentsIndex([])).toBe(""); + }); + + it("lists successful downloads with path and size", () => { + const out = formatAttachmentsIndex([ + ok("mockup.png", "image/png", 348_192), + ok("api-sample.json", "application/json", 2048), + ]); + expect(out).toContain("## Attachments"); + expect(out).toContain("/tmp/attachments/"); + expect(out).toContain("`/tmp/attachments/mockup.png` — image/png, 340 KB"); + expect(out).toContain("`/tmp/attachments/api-sample.json` — application/json, 2 KB"); + }); + + it("marks failed downloads with a warning prefix and reason", () => { + const out = formatAttachmentsIndex([ + fail("spec.pdf", "HTTP 500", 3), + ]); + expect(out).toContain("⚠️"); + expect(out).toContain("spec.pdf"); + expect(out).toContain("failed to download after 3 attempts"); + expect(out).toContain("HTTP 500"); + }); + + it("renders a mix of success and failure", () => { + const out = formatAttachmentsIndex([ + ok("mockup.png", "image/png", 340_000), + fail("broken.bin", "HTTP 404", 1), + ]); + expect(out).toContain("mockup.png"); + expect(out).toContain("⚠️"); + expect(out).toContain("broken.bin"); + }); + + it("renders the section even when all entries failed", () => { + const out = formatAttachmentsIndex([ + fail("a.pdf", "HTTP 500", 3), + fail("b.pdf", "HTTP 500", 3), + ]); + expect(out).toContain("## Attachments"); + expect(out.match(/⚠️/g)?.length).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/sandbox/attachments.test.ts` +Expected: FAIL — `Cannot find module './attachments.js'`. + +- [ ] **Step 3: Create `src/sandbox/attachments.ts` with the helpers** + +Create the file with: + +```ts +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; + +export interface DownloadedAttachment { + filename: string; + originalFilename: string; + mimeType: string; + size: number; + content?: Buffer; + failed?: { reason: string; attempts: number }; +} + +export interface AttachmentCaps { + maxFileSizeBytes: number; + maxTotalSizeBytes: number; + maxCount: number; + downloadTimeoutMs: number; +} + +export function sanitizeFilename(name: string, id: string): string { + // Strip path separators, null bytes, and leading dots (no hidden files). + const cleaned = (name ?? "") + .replace(/[\\/]/g, "") + .replace(/\u0000/g, "") + .replace(/^\.+/, ""); + + // Fallback to `attachment-{id}` only when the result is empty, per spec. + // An extension-only input like ".pdf" legitimately sanitizes to "pdf" and does + // NOT trigger the fallback. + return cleaned.length > 0 ? cleaned : `attachment-${id}`; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const kb = n / 1024; + if (kb < 1024) { + return Number.isInteger(kb) ? `${kb} KB` : `${roundOne(kb)} KB`; + } + const mb = kb / 1024; + return Number.isInteger(mb) ? `${mb} MB` : `${roundOne(mb)} MB`; +} + +function roundOne(x: number): string { + // One decimal, but drop trailing ".0" (e.g. 340.0 -> "340"). + const rounded = Math.round(x * 10) / 10; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); +} + +export function formatAttachmentsIndex( + attachments: DownloadedAttachment[], +): string { + if (attachments.length === 0) return ""; + + const lines: string[] = []; + lines.push("## Attachments"); + lines.push(""); + lines.push( + "The following files from the Jira ticket are available in `/tmp/attachments/`.", + ); + lines.push("Read them when relevant to the task."); + lines.push(""); + + for (const a of attachments) { + if (a.failed) { + lines.push( + `- ⚠️ \`${a.originalFilename}\` — failed to download after ${a.failed.attempts} attempt${a.failed.attempts === 1 ? "" : "s"} (${a.failed.reason})`, + ); + } else { + lines.push( + `- \`/tmp/attachments/${a.filename}\` — ${a.mimeType}, ${formatBytes(a.size)}`, + ); + } + } + + return lines.join("\n"); +} + +// Placeholder export so the workflow step can import the name in Task 7; real +// implementation lands in Task 6. +export async function fetchAttachmentsWithRetry( + _downloader: { downloadAttachment(url: string, opts?: { timeoutMs?: number }): Promise }, + _attachments: TicketAttachment[], + _caps: AttachmentCaps, + _log: { info: (obj: unknown, msg?: string) => void; warn: (obj: unknown, msg?: string) => void }, +): Promise { + throw new Error("fetchAttachmentsWithRetry: not implemented (Task 6)"); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/sandbox/attachments.test.ts` +Expected: PASS — all 14 cases pass. + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +--- + +## Task 6: Implement `fetchAttachmentsWithRetry` + +**Files:** +- Modify: `src/sandbox/attachments.ts` +- Modify: `src/sandbox/attachments.test.ts` + +Behavior (from the spec): +- Enforce caps **before** downloading (use metadata `size`): + - Count cap: skip any attachment whose index >= `maxCount`. + - Per-file cap: skip any whose `size > maxFileSizeBytes`. + - Total cap: track a running sum of bytes that will be downloaded; skip once `sum + size > maxTotalSizeBytes`. +- Retry loop per file: max 3 attempts, backoffs `500ms`, `2000ms`, `5000ms`. +- Retryable: network errors (`AbortError`, `ECONNRESET`, `ETIMEDOUT`), HTTP 5xx, HTTP 429. +- Non-retryable: other 4xx (message already contains the code from `downloadAttachment`'s thrown error). +- 429 is retried using the normal backoff schedule. The spec mentions honoring a `Retry-After` header capped at 10s; this plan deliberately defers header-aware backoff to a follow-up because the current `downloadAttachment` throws a plain `Error` that discards headers. Refactoring to a richer error type is out of scope for v1 — document the gap and move on. A 429 from Jira's attachment CDN is rare enough that the normal 500/2000ms backoff is adequate for v1. +- Collisions: if the sanitized filename already exists in the accumulator, append `-{id}` before the extension. +- Return an array in the **original input order**, with skipped entries included as `failed` entries (reason `"skipped: per-file size cap"`, `"skipped: total size cap"`, or `"skipped: count cap"`) and zero attempts. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/sandbox/attachments.test.ts`: + +```ts +import { fetchAttachmentsWithRetry, type AttachmentCaps } from "./attachments.js"; +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; +import { vi } from "vitest"; + +const defaultCaps: AttachmentCaps = { + maxFileSizeBytes: 25 * 1024 * 1024, + maxTotalSizeBytes: 100 * 1024 * 1024, + maxCount: 20, + downloadTimeoutMs: 30_000, +}; + +function noopLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +function meta( + id: string, + filename: string, + size: number, + mimeType = "application/octet-stream", +): TicketAttachment { + return { + id, + filename, + mimeType, + size, + contentUrl: `https://jira.example/attachment/${id}`, + }; +} + +describe("fetchAttachmentsWithRetry", () => { + it("downloads all attachments when under caps", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([1, 2, 3])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.png", 3), meta("2", "b.png", 3)], + defaultCaps, + noopLogger(), + ); + expect(out).toHaveLength(2); + expect(out[0].content).toBeInstanceOf(Buffer); + expect(out[0].failed).toBeUndefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("skips attachments over per-file cap without downloading", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxFileSizeBytes: 100 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "small.bin", 50), meta("2", "big.bin", 10_000)], + caps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(out[1].failed?.reason).toMatch(/per-file size cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("stops downloading once total cap is exceeded", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxTotalSizeBytes: 150 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [ + meta("1", "a.bin", 100), + meta("2", "b.bin", 100), // 100+100 = 200 > 150 → skipped + meta("3", "c.bin", 40), // 100+40 = 140 ≤ 150 → would fit, but spec says + // "once exceeded, remaining attachments are skipped" + ], + caps, + noopLogger(), + ); + expect(out[0].failed).toBeUndefined(); + expect(out[1].failed?.reason).toMatch(/total size cap/); + expect(out[2].failed?.reason).toMatch(/total size cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("skips attachments beyond count cap", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxCount: 2 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [ + meta("1", "a.bin", 10), + meta("2", "b.bin", 10), + meta("3", "c.bin", 10), + ], + caps, + noopLogger(), + ); + expect(out).toHaveLength(3); + expect(out[0].failed).toBeUndefined(); + expect(out[1].failed).toBeUndefined(); + expect(out[2].failed?.reason).toMatch(/count cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("retries transient 5xx up to 3 times then marks failed", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValue(new Error("Jira attachment error: 500 Internal Server Error on url")), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 10)], + defaultCaps, + noopLogger(), + ); + expect(out[0].failed).toBeDefined(); + expect(out[0].failed?.attempts).toBe(3); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 404", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValue(new Error("Jira attachment error: 404 Not Found on url")), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 10)], + defaultCaps, + noopLogger(), + ); + expect(out[0].failed).toBeDefined(); + expect(out[0].failed?.attempts).toBe(1); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("succeeds on second attempt after transient failure", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValueOnce(new Error("Jira attachment error: 503 Service Unavailable on url")) + .mockResolvedValueOnce(Buffer.from([9])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(out[0].failed).toBeUndefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("resolves collisions by appending -{id} before the extension", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([1])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "report.pdf", 1), meta("2", "report.pdf", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].filename).toBe("report.pdf"); + expect(out[1].filename).toBe("report-2.pdf"); + expect(out[1].originalFilename).toBe("report.pdf"); + }); + + it("retries on network abort errors", async () => { + const abortErr = Object.assign(new Error("The operation was aborted"), { + name: "AbortError", + }); + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValueOnce(abortErr) + .mockResolvedValueOnce(Buffer.from([1])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); +}); +``` + +**Note:** the retry tests will take up to a few seconds because of the backoff. That's acceptable — a 500/2000/5000 ms schedule means the 3-attempt failure case waits ~7.5s. If this feels too slow in CI, see "Speeding up retry tests" below. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/sandbox/attachments.test.ts` +Expected: FAIL — most `fetchAttachmentsWithRetry` tests throw "not implemented" from the placeholder. + +- [ ] **Step 3: Replace the placeholder with a real implementation** + +In `src/sandbox/attachments.ts`, replace the `fetchAttachmentsWithRetry` placeholder function (and anything after it) with: + +```ts +// MAX_ATTEMPTS = 3 means at most 2 sleeps between 3 tries. The spec phrases this +// as "500 → 2000 → 5000ms" but with only 3 attempts the 5000ms delay never fires +// (the 3rd failure exits the loop). We encode just the two delays that actually +// run to avoid confusing dead-code. +const MAX_ATTEMPTS = 3; +const BACKOFFS_MS = [500, 2000]; + +interface Downloader { + downloadAttachment(url: string, opts?: { timeoutMs?: number }): Promise; +} + +interface AttachmentsLogger { + info: (obj: unknown, msg?: string) => void; + warn: (obj: unknown, msg?: string) => void; +} + +export async function fetchAttachmentsWithRetry( + downloader: Downloader, + attachments: TicketAttachment[], + caps: AttachmentCaps, + log: AttachmentsLogger, +): Promise { + const result: DownloadedAttachment[] = []; + const usedFilenames = new Set(); + let bytesCommitted = 0; + let totalCapTripped = false; + + for (let i = 0; i < attachments.length; i++) { + const att = attachments[i]; + + // Cap: count + if (i >= caps.maxCount) { + result.push(skip(att, "skipped: count cap", log)); + continue; + } + // Cap: per-file size + if (att.size > caps.maxFileSizeBytes) { + result.push(skip(att, "skipped: per-file size cap", log)); + continue; + } + // Cap: total size — once exceeded, all remaining are skipped. + if (totalCapTripped || bytesCommitted + att.size > caps.maxTotalSizeBytes) { + totalCapTripped = true; + result.push(skip(att, "skipped: total size cap", log)); + continue; + } + + const safeName = resolveFilename(att, usedFilenames); + usedFilenames.add(safeName); + + let attempts = 0; + let lastError: Error | undefined; + while (attempts < MAX_ATTEMPTS) { + attempts++; + try { + const content = await downloader.downloadAttachment(att.contentUrl, { + timeoutMs: caps.downloadTimeoutMs, + }); + bytesCommitted += att.size; + log.info( + { + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + attempts, + }, + "attachment downloaded", + ); + result.push({ + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + content, + }); + lastError = undefined; + break; + } catch (err) { + lastError = err as Error; + if (!isRetryable(lastError) || attempts >= MAX_ATTEMPTS) break; + const delay = Math.min(BACKOFFS_MS[attempts - 1] ?? 5000, 10_000); + await new Promise((r) => setTimeout(r, delay)); + } + } + + if (lastError) { + log.warn( + { + filename: att.filename, + reason: lastError.message, + attempts, + }, + "attachment failed", + ); + result.push({ + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + failed: { reason: shortReason(lastError.message), attempts }, + }); + } + } + + return result; +} + +function skip( + att: TicketAttachment, + reason: string, + log: AttachmentsLogger, +): DownloadedAttachment { + log.warn({ filename: att.filename, reason }, "attachment skipped"); + return { + filename: sanitizeFilename(att.filename, att.id), + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + failed: { reason, attempts: 0 }, + }; +} + +function resolveFilename( + att: TicketAttachment, + used: Set, +): string { + const safe = sanitizeFilename(att.filename, att.id); + if (!used.has(safe)) return safe; + const dot = safe.lastIndexOf("."); + if (dot <= 0) return `${safe}-${att.id}`; + return `${safe.slice(0, dot)}-${att.id}${safe.slice(dot)}`; +} + +function isRetryable(err: Error): boolean { + const msg = err.message ?? ""; + if (err.name === "AbortError") return true; + if (/ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN/i.test(msg)) return true; + if (/\b5\d\d\b/.test(msg)) return true; + if (/\b429\b/.test(msg)) return true; + return false; +} + +function shortReason(msg: string): string { + // Strip the URL from thrown messages for cleaner index output. + const m = msg.match(/\b(\d{3})\b(.*?)(?: on https?:\/\/.*)?$/); + if (m) return `HTTP ${m[1]}${m[2] ?? ""}`.trim(); + return msg; +} +``` + +**Speeding up retry tests:** if the 500ms/2000ms/5000ms backoffs make Vitest slow enough to annoy you, add an env-gated override at the top of the retry loop: + +```ts +const backoffMultiplier = process.env.ATTACHMENTS_TEST_FAST_RETRY === "1" ? 0 : 1; +// ... +const delay = Math.min((BACKOFFS_MS[attempts - 1] ?? 5000) * backoffMultiplier, 10_000); +``` + +Then run retry tests with `ATTACHMENTS_TEST_FAST_RETRY=1 npx vitest run src/sandbox/attachments.test.ts`. This is optional — if you don't add it, the failing-retry test takes ~7.5s, which is fine. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/sandbox/attachments.test.ts` +Expected: PASS — all 9 `fetchAttachmentsWithRetry` tests plus the earlier helper tests pass. + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +--- + +## Task 7: Thread attachments through `assembleXContext` + +**Files:** +- Modify: `src/sandbox/context.ts` +- Modify: `src/sandbox/context.test.ts` + +Each context function gains an optional `attachments?: DownloadedAttachment[]` field on its input interface. When present and non-empty, the `## Attachments` section is inserted **after** the ticket identifier/title block and **before** `## Description` (for research) or `## Acceptance Criteria` (for the other three, which have no description). + +- [ ] **Step 1: Write the failing tests** + +Replace the existing `describe("assembleResearchPlanContext", ...)` block (and add new cases in other `describe`s) in `src/sandbox/context.test.ts` so that it also exercises attachments. Add the following test cases at the end of each of the four `describe` blocks: + +```ts + it("renders attachments index when attachments are provided", () => { + const result = assembleResearchPlanContext({ + ticket: { + identifier: "TEST-3", + title: "With files", + description: "desc", + acceptanceCriteria: "ac", + comments: [], + }, + prompt: "prompt", + branchName: "blazebot/test-3", + attachments: [ + { + filename: "mockup.png", + originalFilename: "mockup.png", + mimeType: "image/png", + size: 348_192, + content: Buffer.from([]), + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("/tmp/attachments/mockup.png"); + expect(result).toContain("image/png"); + + // Attachments section appears before Description + const atIdx = result.indexOf("## Attachments"); + const descIdx = result.indexOf("## Description"); + expect(atIdx).toBeGreaterThan(-1); + expect(descIdx).toBeGreaterThan(atIdx); + }); + + it("omits attachments section when list is empty or absent", () => { + const withoutField = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + }); + expect(withoutField).not.toContain("## Attachments"); + + const withEmpty = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + attachments: [], + }); + expect(withEmpty).not.toContain("## Attachments"); + }); + + it("shows failed attachments in the index even when no bytes downloaded", () => { + const result = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + attachments: [ + { + filename: "spec.pdf", + originalFilename: "spec.pdf", + mimeType: "application/pdf", + size: 0, + failed: { reason: "HTTP 500", attempts: 3 }, + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("⚠️"); + expect(result).toContain("spec.pdf"); + }); +``` + +Add the same three tests (adapted) to the `describe("assembleImplementationContext ...")`, `describe("assembleImplementationRetryContext ...")`, and `describe("assembleReviewContext ...")` blocks. In the "before Description" ordering check for the three non-research functions, replace `## Description` with `## Acceptance Criteria`: + +```ts + const atIdx = result.indexOf("## Attachments"); + const acIdx = result.indexOf("## Acceptance Criteria"); + expect(atIdx).toBeGreaterThan(-1); + expect(acIdx).toBeGreaterThan(atIdx); +``` + +For `assembleImplementationRetryContext` and `assembleReviewContext`, include the required extra inputs (`researchPlanMarkdown`, `reviewFeedback`, `gitDiff`) as in the existing tests. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/sandbox/context.test.ts` +Expected: FAIL — the `attachments` field is rejected by TypeScript on the four context input interfaces, and the assertions fail because no `## Attachments` text exists. + +- [ ] **Step 3: Update `src/sandbox/context.ts`** + +At the top of the file, add: + +```ts +import type { DownloadedAttachment } from "./attachments.js"; +import { formatAttachmentsIndex } from "./attachments.js"; +``` + +Add `attachments?: DownloadedAttachment[]` to each of the four input interfaces: + +```ts +export interface ResearchPlanContextInput { + ticket: TicketData; + prompt: string; + branchName: string; + prComments?: PRComment[]; + checkResults?: CheckRunResult[]; + hasConflicts?: boolean; + attachments?: DownloadedAttachment[]; +} + +export interface ImplementationContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + attachments?: DownloadedAttachment[]; +} + +export interface ImplementationRetryContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + reviewFeedback: ReviewOutput; + attachments?: DownloadedAttachment[]; +} + +export interface ReviewContextInput { + ticket: TicketData; + prompt: string; + researchPlanMarkdown: string; + gitDiff: string; + attachments?: DownloadedAttachment[]; +} +``` + +Then rewrite each of the four `assembleXContext` functions to insert the attachments index between the Ticket header and the next section. Use this pattern (apply to all four — shown for research here; adapt the specific sections for the other three): + +For `assembleResearchPlanContext`, replace the implementation with: + +```ts +export function assembleResearchPlanContext(input: ResearchPlanContextInput): string { + const { ticket, prompt, branchName, prComments, checkResults, hasConflicts, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); + + let md = `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} +${attachmentsSection} +## Description + +${ticket.description} + +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Comments + +${formatComments(ticket.comments)} + +## Branch + +${branchName} +`; + + if (prComments && prComments.length > 0) { + md += `\n## PR Review Feedback\n\n${formatPRComments(prComments)}\n`; + } + if (checkResults && checkResults.length > 0) { + md += `\n## CI/CD Check Results\n\n${formatCheckResults(checkResults)}\n`; + } + if (hasConflicts) { + md += `\n## Merge Conflicts\n\nThis PR has merge conflicts. The base branch has already been merged — the repo is in a MERGING state with conflict markers in the affected files. Resolve the markers, \`git add\` the files, and run \`git merge --continue\`.\n`; + } + + md += `\n---\n\n${prompt}\n`; + return md; +} +``` + +For `assembleImplementationContext`: + +```ts +export function assembleImplementationContext(input: ImplementationContextInput): string { + const { ticket, prompt, researchPlanMarkdown, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} +${attachmentsSection} +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +--- + +${prompt} +`; +} +``` + +For `assembleImplementationRetryContext`: + +```ts +export function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string { + const { ticket, prompt, researchPlanMarkdown, reviewFeedback, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} +${attachmentsSection} +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +## Review Feedback + +${reviewFeedback.feedback} + +### Issues + +${formatReviewIssues(reviewFeedback.issues)} + +--- + +${prompt} +`; +} +``` + +For `assembleReviewContext`: + +```ts +export function assembleReviewContext(input: ReviewContextInput): string { + const { ticket, prompt, researchPlanMarkdown, gitDiff, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); + return `# Requirements + +## Ticket ID + +${ticket.identifier} + +## Ticket + +${ticket.title} +${attachmentsSection} +## Acceptance Criteria + +${ticket.acceptanceCriteria || "None specified."} + +## Research & Plan + +${researchPlanMarkdown} + +## Git Diff + +\`\`\`diff +${gitDiff} +\`\`\` + +--- + +${prompt} +`; +} +``` + +Finally, add this private helper at the bottom of the file (below the other `format*` helpers): + +```ts +function renderAttachmentsSection( + attachments: DownloadedAttachment[] | undefined, +): string { + if (!attachments || attachments.length === 0) return ""; + return `\n${formatAttachmentsIndex(attachments)}\n`; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/sandbox/context.test.ts` +Expected: PASS — all existing tests still pass, plus the four new sets of attachments tests. + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +--- + +## Task 8: Wire workflow steps `fetchAttachments` and `writeAttachments` + +**Files:** +- Modify: `src/workflows/agent.ts` + +This is the integration task. Two new `"use step"` functions run in sequence between `provisionSandbox` (line ~256) and the Phase 1 research block. The downloaded-attachments array is captured in workflow-local state and passed to all four `assembleXContext` calls (research, impl, impl retry, review). + +Key points: +- `fetchAndValidateTicket` already returns a `ticket`; after Task 2 its shape includes `attachments: TicketAttachment[]`. Forward that array into the new step. +- `fetchAttachments` is pure-ish (HTTP + retry) and should **not** throw on per-file failures — that's the spec contract. Set `fetchAttachments.maxRetries = 0` so WDK doesn't re-run the whole step on a partial failure that was already handled. +- `writeAttachments` writes to the shared sandbox. Set `writeAttachments.maxRetries = 0` like `writeAndStartPhase` does; a persistent failure is a real sandbox issue and should fail fast. +- `DownloadedAttachment` includes `Buffer` — Buffers serialize across WDK step boundaries fine (they become `Uint8Array` under the hood; `sandbox.writeFiles` accepts `Buffer`). If any issue surfaces in practice, swap to `{ path, content }` tuples at the step boundary. + +- [ ] **Step 1: Add the `fetchAttachments` step function** + +In `src/workflows/agent.ts`, immediately after the `fetchAndValidateTicket` function (around line 16), insert: + +```ts +async function fetchAttachments( + ticketIdentifier: string, + attachments: Array<{ + id: string; + filename: string; + mimeType: string; + size: number; + contentUrl: string; + }>, +) { + "use step"; + const { logger } = await import("../lib/logger.js"); + const log = logger.child({ ticket_identifier: ticketIdentifier, step: "fetchAttachments" }); + log.info({ count: attachments.length }, "fetchAttachments: start"); + + if (attachments.length === 0) { + log.info({}, "fetchAttachments: no attachments"); + return []; + } + + const { env } = await import("../../env.js"); + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { fetchAttachmentsWithRetry } = await import("../sandbox/attachments.js"); + const { issueTracker } = createStepAdapters(); + + // The JiraAdapter exposes downloadAttachment. Other issue-tracker adapters don't + // (yet), so guard it here — if we ever add more, the workflow will just skip + // attachments for trackers without a downloader. + const downloader = issueTracker as unknown as { + downloadAttachment?: (url: string, opts?: { timeoutMs?: number }) => Promise; + }; + if (typeof downloader.downloadAttachment !== "function") { + log.warn( + { tracker: issueTracker.constructor.name }, + "issue tracker does not support attachment downloads; skipping", + ); + return []; + } + + const result = await fetchAttachmentsWithRetry( + downloader as { downloadAttachment: (url: string, opts?: { timeoutMs?: number }) => Promise }, + attachments, + { + maxFileSizeBytes: env.ATTACHMENT_MAX_FILE_SIZE_MB * 1024 * 1024, + maxTotalSizeBytes: env.ATTACHMENT_MAX_TOTAL_SIZE_MB * 1024 * 1024, + maxCount: env.ATTACHMENT_MAX_COUNT, + downloadTimeoutMs: env.ATTACHMENT_DOWNLOAD_TIMEOUT_MS, + }, + log, + ); + log.info( + { + succeeded: result.filter((a) => !a.failed).length, + failed: result.filter((a) => a.failed).length, + }, + "fetchAttachments: done", + ); + return result; +} +fetchAttachments.maxRetries = 0; +``` + +- [ ] **Step 2: Add the `writeAttachments` step function** + +Immediately after `fetchAttachments`, add: + +```ts +async function writeAttachments( + sandboxId: string, + attachments: Array<{ + filename: string; + originalFilename: string; + mimeType: string; + size: number; + content?: Buffer | Uint8Array; + failed?: { reason: string; attempts: number }; + }>, +): Promise { + "use step"; + const { logger } = await import("../lib/logger.js"); + const log = logger.child({ sandboxId, step: "writeAttachments" }); + + const toWrite = attachments.filter((a) => a.content && !a.failed); + log.info( + { count: toWrite.length, totalReceived: attachments.length }, + "writeAttachments: start", + ); + if (toWrite.length === 0) { + log.info({}, "writeAttachments: nothing to write"); + return; + } + + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Ensure target directory exists — writeFiles does not guarantee mkdir -p semantics. + await sandbox.runCommand("mkdir", ["-p", "/tmp/attachments"]); + + await sandbox.writeFiles( + toWrite.map((a) => ({ + path: `/tmp/attachments/${a.filename}`, + content: Buffer.isBuffer(a.content) + ? (a.content as Buffer) + : Buffer.from(a.content as Uint8Array), + })), + ); + log.info({ count: toWrite.length }, "writeAttachments: done"); +} +writeAttachments.maxRetries = 0; +``` + +- [ ] **Step 3: Call the two new steps in `agentWorkflow`** + +The spec's architecture diagram places `fetchAttachments` at workflow start (before `createFeatureBranch` / `provisionSandbox`) and `writeAttachments` after `provisionSandbox`. Placement matters: + +- Download **before** `provisionSandbox` so a slow/partial-failure download doesn't burn sandbox CPU hours while idle. +- Write **inside** the `try { ... }` block so a thrown `writeAttachments` always routes through `finally { teardownSandbox }` — no leaked sandbox. + +Inside `agentWorkflow`, locate this block (in the top half of the `try { ... }` outer body, after `createFeatureBranch` handling): + +```ts + const mergeBase = prContext?.hasConflicts ? baseBranch : undefined; + + // Provision sandbox once for all phases + const sandboxId = await provisionSandbox(branchName, mergeBase); + + try { +``` + +Insert the `fetchAttachments` call **immediately before** `const sandboxId = await provisionSandbox(...)`: + +```ts + const downloadedAttachments = await fetchAttachments(ticket.identifier, ticket.attachments); +``` + +So that block becomes: + +```ts + const mergeBase = prContext?.hasConflicts ? baseBranch : undefined; + + const downloadedAttachments = await fetchAttachments(ticket.identifier, ticket.attachments); + + // Provision sandbox once for all phases + const sandboxId = await provisionSandbox(branchName, mergeBase); + + try { +``` + +Then, as the **first** action inside `try { ... }` (above `// ========== PHASE 1: Research & Plan ==========`), insert: + +```ts + await writeAttachments(sandboxId, downloadedAttachments); +``` + +- [ ] **Step 4: Forward attachments to every `assembleXContext` call** + +Locate the four `assembleXContext` calls in `agentWorkflow` and add an `attachments: downloadedAttachments` property to each. + +Research (around line 270): + +```ts + const researchInput = assembleResearchPlanContext({ + ticket: ticketData, + prompt: getPrompt("research-plan.md"), + branchName, + prComments: prContext?.prComments, + checkResults: prContext?.checkResults, + hasConflicts: prContext?.hasConflicts, + attachments: downloadedAttachments, + }); +``` + +Implementation retry / first (around line 336): + +```ts + const implInput = lastReviewFeedback + ? assembleImplementationRetryContext({ + ticket: ticketData, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + reviewFeedback: lastReviewFeedback, + attachments: downloadedAttachments, + }) + : assembleImplementationContext({ + ticket: ticketData, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + attachments: downloadedAttachments, + }); +``` + +Review (around line 400): + +```ts + const reviewInput = assembleReviewContext({ + ticket: ticketData, + prompt: getPrompt("review.md"), + researchPlanMarkdown, + gitDiff, + attachments: downloadedAttachments, + }); +``` + +- [ ] **Step 5: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. The `ticket.attachments` field is now required on `TicketContent`, so ensure any place that constructs a `TicketContent` (primarily the Jira adapter and fixtures) already includes it. If typecheck flags other call sites, fix them — most likely a test fixture that builds `TicketContent` manually. + +- [ ] **Step 6: Run the full unit test suite** + +Run: `npm run test` +Expected: all suites pass. Pay special attention to `src/adapters/issue-tracker/jira.test.ts`, `src/sandbox/context.test.ts`, and `src/sandbox/attachments.test.ts`. + +--- + +## Task 9: End-to-end integration sanity check (mocked sandbox) + +**Files:** +- Create: `src/sandbox/attachments.integration.test.ts` + +This is a light integration test that proves the three moving pieces line up: Jira mock → `fetchAttachmentsWithRetry` → a fake sandbox's `writeFiles`. It does **not** spin up a real sandbox or a real workflow — it exercises the shapes. + +- [ ] **Step 1: Write the test** + +Create `src/sandbox/attachments.integration.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { fetchAttachmentsWithRetry, type AttachmentCaps } from "./attachments.js"; +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; + +describe("attachments → sandbox writeFiles shape", () => { + it("produces writeFiles payloads at /tmp/attachments/", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])) + .mockResolvedValueOnce(Buffer.from("{\"ok\":true}")), + }; + + const attachments: TicketAttachment[] = [ + { + id: "1", + filename: "mockup.png", + mimeType: "image/png", + size: 4, + contentUrl: "https://jira.example/1", + }, + { + id: "2", + filename: "sample.json", + mimeType: "application/json", + size: 11, + contentUrl: "https://jira.example/2", + }, + ]; + + const caps: AttachmentCaps = { + maxFileSizeBytes: 1_000_000, + maxTotalSizeBytes: 10_000_000, + maxCount: 10, + downloadTimeoutMs: 5_000, + }; + + const downloaded = await fetchAttachmentsWithRetry( + downloader, + attachments, + caps, + { info: vi.fn(), warn: vi.fn() }, + ); + + // Simulate the writeAttachments step's payload mapping. + const payload = downloaded + .filter((a) => a.content && !a.failed) + .map((a) => ({ + path: `/tmp/attachments/${a.filename}`, + content: a.content!, + })); + + expect(payload).toHaveLength(2); + expect(payload[0].path).toBe("/tmp/attachments/mockup.png"); + expect(payload[0].content).toBeInstanceOf(Buffer); + expect(payload[1].path).toBe("/tmp/attachments/sample.json"); + }); +}); +``` + +- [ ] **Step 2: Run the integration test** + +Run: `npx vitest run src/sandbox/attachments.integration.test.ts` +Expected: PASS. + +--- + +## Task 10: Final verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `npm run test` +Expected: all tests pass. + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +- [ ] **Step 3: Spot-check the generated requirements.md shape manually** + +Run: +```bash +node --input-type=module -e " +import { assembleResearchPlanContext } from './src/sandbox/context.ts'; +console.log(assembleResearchPlanContext({ + ticket: { + identifier: 'TEST-1', + title: 'Example', + description: 'desc', + acceptanceCriteria: 'ac', + comments: [], + }, + prompt: 'PROMPT', + branchName: 'blazebot/test-1', + attachments: [ + { filename: 'mockup.png', originalFilename: 'mockup.png', mimeType: 'image/png', size: 348192, content: Buffer.from([]) }, + { filename: 'spec.pdf', originalFilename: 'spec.pdf', mimeType: 'application/pdf', size: 0, failed: { reason: 'HTTP 500', attempts: 3 } }, + ], +})); +" +``` +Expected: output contains: +- A `## Ticket ID` header, followed shortly after by +- A `## Attachments` header +- A line like ``- `/tmp/attachments/mockup.png` — image/png, 340 KB`` +- A line like `- ⚠️ \`spec.pdf\` — failed to download after 3 attempts (HTTP 500)` +- `## Description` appearing **after** `## Attachments` + +- [ ] **Step 4: Review workflow wiring** + +Re-read `src/workflows/agent.ts` and confirm: +- `fetchAttachments(ticket.identifier, ticket.attachments)` is called **before** `provisionSandbox` (per spec architecture). +- `writeAttachments(sandboxId, downloadedAttachments)` is the **first** statement inside `try {`. +- All four `assembleXContext(...)` invocations pass `attachments: downloadedAttachments`. +- Neither step forwards errors that would crash the workflow — `fetchAttachments` always returns an array, `writeAttachments` no-ops when there is nothing to write. + +--- + +## Notes for the implementer + +- **Do not** add `.gitignore` entries for `/tmp/attachments/` — the files live outside the cloned repo and are never staged by `git`. The existing `requirements.md` convention at `/tmp/research-requirements.md` etc. already relies on this. +- **Do not** attempt to follow URLs embedded in the ticket description or comments. That is explicitly out-of-scope (SSRF risk, auth friction). The agent can decide to fetch external URLs itself inside the sandbox if needed. +- **Do not** add attachment counts to Slack messages — v1 keeps Slack unchanged. +- **Do not** dedup by content hash — v1 keeps one-off delivery per ticket. +- If you discover a call site that constructs a `TicketContent` literal (most likely a test fixture) and typecheck complains after Task 2, add `attachments: []` to it. Do not widen the type to make `attachments` optional — the spec is explicit that it is always present, even when empty. +- The `shortReason` regex in Task 6 is intentionally simple; if you find messages it can't parse cleanly, just return the raw message. The index is for the agent, not for humans. diff --git a/docs/superpowers/specs/2026-04-13-jira-ticket-attachments-design.md b/docs/superpowers/specs/2026-04-13-jira-ticket-attachments-design.md new file mode 100644 index 0000000..17a6701 --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-jira-ticket-attachments-design.md @@ -0,0 +1,232 @@ +# Jira Ticket Attachments in the Sandbox — Design + +**Date:** 2026-04-13 +**Status:** Design approved, ready for implementation plan + +## Problem + +Today, the agent receives a Jira ticket as a `requirements.md` blob containing only text: title, description, acceptance criteria, comments. Any files attached to the ticket in Jira (mockups, PDFs of specs, sample JSON fixtures, screenshots) are invisible to the agent. + +We want the agent to have access to those files inside the sandbox so it can read them during research, implementation, and review. + +## Scope + +**In scope** +- Jira attachments (files uploaded to the issue via the Jira UI). +- All three phases on the same sandbox run (research, implement, review) see the same attachments. +- Best-effort delivery: a single broken attachment does not fail the workflow. + +**Out of scope (v1)** +- Following external URLs found in the ticket description/comments. Links stay as text in `requirements.md` for the agent to decide whether to fetch manually inside the sandbox. +- Cross-ticket attachment reuse ("knowledge pack" of files that outlive a single ticket). +- Attachment previews in Slack notifications. +- Content-hash dedup across tickets. + +## Key decisions + +1. **Jira attachments only.** URL-following was considered and rejected — it introduces SSRF risk, auth headaches (OAuth for Figma/Drive), unpredictable size/content, and is not needed for the v1 use case. +2. **Stage into `/tmp/attachments/` in the sandbox, not into the repo.** `requirements.md` already lives in `/tmp/`, outside the cloned repo. Placing attachments alongside means they are never in `git diff` and therefore never accidentally committed or pushed. No `.gitignore` plumbing needed. +3. **One sandbox per workflow, one staging pass.** The sandbox is provisioned once per ticket (`src/workflows/agent.ts:256`) and reused across all phases. Attachments are fetched once at workflow start and written once after `provisionSandbox`. +4. **Generated index in `requirements.md`.** The ticket description does not always reference attachments by name (someone may just drag a PNG onto the ticket). A short index at the top of `requirements.md` guarantees the agent knows what exists and where to find it. +5. **Per-file retries with skip-on-failure.** A broken attachment is logged, marked in the index, and does not block the workflow. + +## Architecture + +```text +Workflow start (src/workflows/agent.ts) + ├─ fetchAndValidateTicket (existing) + ├─ fetchAttachments (NEW step — downloads bytes from Jira) + ├─ createFeatureBranch (existing) + ├─ provisionSandbox (existing — one sandbox for the whole workflow) + ├─ writeAttachments (NEW step — writeFiles to /tmp/attachments/) + │ + ├─ Phase 1: Research (requirements.md contains the attachments index) + ├─ Phase 2: Implement (same index, same files on disk) + ├─ Phase 3: Review (same) + │ + └─ teardownSandbox (existing — kills sandbox, attachments die with it) +``` + +Attachments live at `/tmp/attachments/{sanitized-filename}` for the full workflow lifetime. + +## Components + +### 1. `JiraAdapter` — metadata + download + +**File:** `src/adapters/issue-tracker/jira.ts` + +Changes: +- Add `attachment` to the `fields=` query in `fetchTicket`. +- Parse `data.fields.attachment` into a new `attachments: JiraAttachmentMeta[]` field on `TicketContent`. +- New method `downloadAttachment(url: string): Promise`: + - GET with `redirect: "manual"`. On 302, read `Location` and re-GET **without** the `Authorization` header (Atlassian's CDN uses signed URLs; re-sending Basic auth breaks them). + - Timeout: 30s (AbortSignal). + - Max redirects: 1. + +**New type:** `TicketAttachment` added to `src/adapters/issue-tracker/types.ts`: + +```ts +export interface TicketAttachment { + id: string; + filename: string; + mimeType: string; + size: number; + contentUrl: string; +} +``` + +Added to `TicketContent`: + +```ts +export interface TicketContent { + // ...existing fields + attachments: TicketAttachment[]; +} +``` + +### 2. Workflow step — `fetchAttachments` + +**File:** `src/workflows/agent.ts` (new step) and a helper in `src/sandbox/attachments.ts` (new file). + +Signature: + +```ts +async function fetchAttachments( + attachments: TicketAttachment[] +): Promise +``` + +`DownloadedAttachment`: + +```ts +interface DownloadedAttachment { + filename: string; // sanitized, collision-resolved + originalFilename: string; + mimeType: string; + size: number; + content: Buffer; // present only on success + failed?: { reason: string; attempts: number }; // present only on failure +} +``` + +Behavior: +- Iterate attachments in Jira-returned order. +- Enforce caps (see "Safety caps" below) before calling download. +- Call `JiraAdapter.downloadAttachment(url)` with a per-file retry loop (see "Retries"). +- Sanitize filename: strip path separators (`/`, `\`), null bytes, leading dots; fall back to `attachment-{id}{ext}` if result is empty. +- Collision handling: if the sanitized filename already exists in the accumulator, append `-{id}` before the extension. +- On download failure after retries, include a `failed` entry (no `content`) so the index can reflect it. + +### 3. Workflow step — `writeAttachments` + +**File:** `src/workflows/agent.ts` (new step). + +```ts +async function writeAttachments( + sandboxId: string, + attachments: DownloadedAttachment[] +): Promise +``` + +- `Sandbox.get({ sandboxId })` then `sandbox.writeFiles(...)` for every entry with `content` defined. +- Path: `/tmp/attachments/{filename}`. +- Skip failed entries (no bytes to write). + +### 4. `context.ts` — attachments index + +**File:** `src/sandbox/context.ts` + +Add an `attachments?: DownloadedAttachment[]` parameter to all four `assembleXContext` functions (`assembleResearchPlanContext`, `assembleImplementationContext`, `assembleImplementationRetryContext`, `assembleReviewContext`). + +New helper `formatAttachmentsIndex(attachments)`: + +```md +## Attachments + +The following files from the Jira ticket are available in `/tmp/attachments/`. +Read them when relevant to the task. + +- `/tmp/attachments/mockup.png` — image/png, 340 KB +- `/tmp/attachments/api-sample.json` — application/json, 2 KB +- ⚠️ `spec.pdf` — failed to download after 3 attempts (HTTP 500) +``` + +- Section inserted **once**, right after the `## Ticket ID` / `## Ticket` header block and before `## Description`. +- Omitted entirely only when the ticket had **zero attachments** in Jira. If attachments existed but all failed to download, the section still appears with every entry marked as failed. +- Human-readable size (`340 KB`, `1.2 MB`) via a small `formatBytes` helper. + +### 5. `src/sandbox/attachments.ts` (new file) + +Exports: +- `fetchAttachmentsWithRetry(jiraAdapter, attachments, caps, logger)` — the core loop used by the workflow step. +- `sanitizeFilename(name, id)` — pure utility. +- `formatAttachmentsIndex(attachments)` — pure formatter. +- `formatBytes(n)` — pure utility. + +Kept out of `jira.ts` because retry/caps/sanitize logic is Blazebot-specific, not part of the adapter contract. + +## Safety caps + +Env-configurable with sane defaults. Declared in `env.ts`: + +| Variable | Default | Meaning | +|----------|---------|---------| +| `ATTACHMENT_MAX_FILE_SIZE_MB` | 25 | Per-file cap. Oversize files are skipped and noted in the index. | +| `ATTACHMENT_MAX_TOTAL_SIZE_MB` | 100 | Cumulative cap. Once exceeded, remaining attachments are skipped. | +| `ATTACHMENT_MAX_COUNT` | 20 | Hard cap on number of attachments. | +| `ATTACHMENT_DOWNLOAD_TIMEOUT_MS` | 30000 | Per-download timeout. | + +All caps are applied **before** downloading — cap decisions use the metadata `size` field returned by Jira, so we never fetch bytes we'll throw away. + +## Retries + +Two layers: + +1. **WDK step-level retries are disabled.** `fetchAttachments.maxRetries = 0` and `writeAttachments.maxRetries = 0`. +2. **Per-file retry loop (inside the step).** Implemented in `fetchAttachmentsWithRetry` (called by `fetchAttachments`): + - Max 3 attempts. + - Exponential backoff: 500ms → 2000ms → 5000ms. + - Retryable errors: network errors (`ECONNRESET`, `ETIMEDOUT`, `AbortError`), HTTP 5xx, HTTP 429 (honors `Retry-After` if present, capped at 10s). + - Non-retryable: 4xx other than 429 (401/403/404 typically mean auth/missing, not transient). + - After max attempts: mark the file as failed in the returned array. Do **not** throw from `fetchAttachments` — other attachments and the workflow continue. + +## Observability + +- `pino` logs at `info` for each successfully downloaded attachment: `{ ticketId, filename, mimeType, size, attempts }`. +- `pino` logs at `warn` for each failed or skipped attachment with reason: `{ ticketId, filename, reason, attempts? }`. +- Slack notification text unchanged in v1. (Future: add attachment count to the "started" message.) + +## Testing + +Unit: +- `JiraAdapter.fetchTicket` parses `attachment` field into `TicketAttachment[]` correctly (including empty array when absent). +- `JiraAdapter.downloadAttachment` follows one 302 and drops `Authorization` on the redirect. +- `sanitizeFilename` — path separators, null bytes, empty-after-sanitize fallback, extension preservation. +- `formatAttachmentsIndex` — happy path, all-failed path, empty path (omitted), mixed. +- `formatBytes` — KB/MB rounding. +- `fetchAttachmentsWithRetry` — enforces size/total/count caps without downloading; retries transient errors; gives up on 404; surfaces `failed` entries after exhausting attempts. +- `assembleResearchPlanContext` / implementation / retry / review — emit index when attachments present; omit section when empty. + +Integration: +- End-to-end with a `fetch`-mocked Jira returning 2 attachments (one image, one JSON) → `writeAttachments` called with both → sandbox receives both at expected paths. (Uses existing `@vercel/sandbox` test patterns from `manager.test.ts`.) + +## Failure modes and how we handle them + +| Failure | Behavior | +|---------|----------| +| Jira metadata fetch fails | Existing `fetchAndValidateTicket` step retry handles it (unchanged path). | +| One file 500s | Retry 3×, then mark failed in index, continue. | +| One file 404s | No retry, mark failed in index, continue. | +| File exceeds `ATTACHMENT_MAX_FILE_SIZE_MB` | Skip, mark in index, continue. | +| Total bytes exceeds `ATTACHMENT_MAX_TOTAL_SIZE_MB` | Skip remaining, mark in index, continue. | +| Count exceeds `ATTACHMENT_MAX_COUNT` | Skip overflow, mark in index, continue. | +| All downloads fail | Step still returns an array (all with `failed` set). Index shows all as failed. Workflow continues. | +| `writeAttachments` fails | WDK step retry. If still failing, workflow fails — this is the correct behavior (sandbox is broken). | + +## Migration + +No data migration. New steps are additive. Existing tickets without attachments simply get an empty `attachments` array and no index section. + +## Open questions + +None at design time. All caps are env-configurable so they can be tuned without code changes after v1 ships. diff --git a/env.ts b/env.ts index e5a3273..9fdc8e4 100644 --- a/env.ts +++ b/env.ts @@ -50,6 +50,12 @@ export const env = createEnv({ MAX_CONCURRENT_AGENTS: z.coerce.number().int().positive().default(3), JOB_TIMEOUT_MS: z.coerce.number().int().positive().default(1_800_000), + // Attachments + ATTACHMENT_MAX_FILE_SIZE_MB: z.coerce.number().int().positive().default(25), + ATTACHMENT_MAX_TOTAL_SIZE_MB: z.coerce.number().int().positive().default(100), + ATTACHMENT_MAX_COUNT: z.coerce.number().int().positive().default(20), + ATTACHMENT_DOWNLOAD_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + // Polling POLL_INTERVAL_MS: z.coerce.number().int().positive().default(300_000), diff --git a/src/adapters/issue-tracker/jira.test.ts b/src/adapters/issue-tracker/jira.test.ts index 6c87fa1..8be4cc6 100644 --- a/src/adapters/issue-tracker/jira.test.ts +++ b/src/adapters/issue-tracker/jira.test.ts @@ -36,6 +36,7 @@ describe("JiraAdapter", () => { }, labels: ["frontend"], status: { name: "AI" }, + attachment: [], }, }), }); @@ -48,6 +49,329 @@ describe("JiraAdapter", () => { expect(ticket.title).toBe("Add login page"); expect(ticket.comments).toHaveLength(1); expect(ticket.trackerStatus).toBe("AI"); + expect(ticket.attachments).toEqual([]); + }); + }); + + describe("fetchTicket attachments", () => { + it("parses attachment metadata into TicketAttachment[]", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10001", + key: "PROJ-1", + fields: { + summary: "Has attachments", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [ + { + id: "att-1", + filename: "mockup.png", + mimeType: "image/png", + size: 348192, + content: "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + }, + { + id: "att-2", + filename: "spec.pdf", + mimeType: "application/pdf", + size: 52100, + content: "https://test.atlassian.net/secure/attachment/att-2/spec.pdf", + }, + ], + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10001"); + + expect(ticket.attachments).toHaveLength(2); + expect(ticket.attachments[0]).toEqual({ + id: "att-1", + filename: "mockup.png", + mimeType: "image/png", + size: 348192, + contentUrl: "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + }); + }); + + it("sanitizes malformed attachment sizes", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10001", + key: "PROJ-1", + fields: { + summary: "Has malformed sizes", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [ + { id: "att-1", size: "64", content: "https://test.atlassian.net/1" }, + { id: "att-2", size: "bad", content: "https://test.atlassian.net/2" }, + { id: "att-3", size: -10, content: "https://test.atlassian.net/3" }, + { id: "att-4", size: Number.POSITIVE_INFINITY, content: "https://test.atlassian.net/4" }, + { id: "att-5", size: 7.9, content: "https://test.atlassian.net/5" }, + ], + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10001"); + + expect(ticket.attachments.map((a) => a.size)).toEqual([64, 0, 0, 0, 7]); + }); + + it("omits contentUrl when Jira does not provide attachment content", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10001", + key: "PROJ-1", + fields: { + summary: "Has partial attachment metadata", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [ + { + id: "att-1", + filename: "spec.pdf", + mimeType: "application/pdf", + size: 52100, + }, + ], + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10001"); + + expect(ticket.attachments).toHaveLength(1); + expect(ticket.attachments[0].contentUrl).toBeUndefined(); + }); + + it("returns empty attachments array when field is absent", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10002", + key: "PROJ-2", + fields: { + summary: "No attachments", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + // attachment field intentionally omitted + }, + }), + }); + + const adapter = jiraAdapter(); + const ticket = await adapter.fetchTicket("10002"); + expect(ticket.attachments).toEqual([]); + }); + + it("requests attachment field in the fields query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "10003", + key: "PROJ-3", + fields: { + summary: "x", + description: null, + comment: { comments: [] }, + labels: [], + status: { name: "AI" }, + attachment: [], + }, + }), + }); + + const adapter = jiraAdapter(); + await adapter.fetchTicket("10003"); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain("fields="); + expect(url).toContain("attachment"); + }); + }); + + describe("downloadAttachment", () => { + it("follows one 302 redirect without Authorization header and drains the first body", async () => { + const redirectUrl = "https://atlassian-cdn.example/signed?x=1"; + const cancelFn = vi.fn(); + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + headers: { get: (n: string) => (n.toLowerCase() === "location" ? redirectUrl : null) }, + body: { cancel: cancelFn }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + }); + + const adapter = jiraAdapter(); + const buf = await adapter.downloadAttachment( + "https://test.atlassian.net/secure/attachment/att-1/mockup.png", + ); + + expect(buf).toBeInstanceOf(Buffer); + expect(buf.length).toBe(4); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call: to Jira, with Authorization. + const firstInit = mockFetch.mock.calls[0][1] as RequestInit; + expect((firstInit.headers as Record).Authorization).toMatch(/^Basic /); + expect(firstInit.redirect).toBe("manual"); + + // First response body drained to release the socket back to the pool. + expect(cancelFn).toHaveBeenCalledOnce(); + + // Second call: to the CDN, WITHOUT Authorization. + const secondInit = mockFetch.mock.calls[1][1] as RequestInit; + const secondHeaders = (secondInit.headers ?? {}) as Record; + expect(secondHeaders.Authorization).toBeUndefined(); + expect(mockFetch.mock.calls[1][0]).toBe(redirectUrl); + }); + + it("does not send Authorization when the initial URL is cross-origin", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([1]).buffer, + }); + + const adapter = jiraAdapter(); + await adapter.downloadAttachment("https://atlassian-cdn.example/signed?x=1"); + + const firstInit = mockFetch.mock.calls[0][1] as RequestInit; + const firstHeaders = (firstInit.headers ?? {}) as Record; + expect(firstHeaders.Authorization).toBeUndefined(); + }); + + it("resolves relative redirect targets and keeps Authorization for same-origin refetches", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + headers: { + get: (n: string) => (n.toLowerCase() === "location" ? "/secure/attachment/att-9/file.png?dl=1" : null), + }, + body: { cancel: vi.fn() }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([9]).buffer, + }); + + const adapter = jiraAdapter(); + await adapter.downloadAttachment("https://test.atlassian.net/secure/attachment/att-9/file.png"); + + expect(mockFetch.mock.calls[1][0]).toBe( + "https://test.atlassian.net/secure/attachment/att-9/file.png?dl=1", + ); + const secondInit = mockFetch.mock.calls[1][1] as RequestInit; + expect((secondInit.headers as Record).Authorization).toMatch(/^Basic /); + }); + + it("also follows one 303 redirect", async () => { + const redirectUrl = "https://atlassian-cdn.example/signed-303?x=1"; + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 303, + statusText: "See Other", + headers: { get: (n: string) => (n.toLowerCase() === "location" ? redirectUrl : null) }, + body: { cancel: vi.fn() }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer, + }); + + const adapter = jiraAdapter(); + const buf = await adapter.downloadAttachment( + "https://test.atlassian.net/secure/attachment/att-303/file.png", + ); + + expect(buf).toBeInstanceOf(Buffer); + expect(buf.length).toBe(4); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][0]).toBe(redirectUrl); + const secondInit = mockFetch.mock.calls[1][1] as RequestInit; + const secondHeaders = (secondInit.headers ?? {}) as Record; + expect(secondHeaders.Authorization).toBeUndefined(); + }); + + it("drains body and throws when redirect is missing Location", async () => { + const cancelFn = vi.fn(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + headers: { get: () => null }, + body: { cancel: cancelFn }, + }); + + const adapter = jiraAdapter(); + await expect( + adapter.downloadAttachment("https://test.atlassian.net/secure/attachment/att-1/missing"), + ).rejects.toThrow(/missing Location header/i); + expect(cancelFn).toHaveBeenCalledOnce(); + }); + + it("returns bytes directly on 200 (no redirect)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + }); + + const adapter = jiraAdapter(); + const buf = await adapter.downloadAttachment( + "https://test.atlassian.net/secure/attachment/att-1/data.bin", + ); + expect(Array.from(buf)).toEqual([1, 2, 3]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws on non-2xx, non-redirect responses", async () => { + const cancelFn = vi.fn(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: { get: () => null }, + body: { cancel: cancelFn }, + }); + + const adapter = jiraAdapter(); + await expect( + adapter.downloadAttachment("https://test.atlassian.net/secure/attachment/att-1/x"), + ).rejects.toThrow(/500/); + expect(cancelFn).toHaveBeenCalledOnce(); }); it("throws IssueTrackerNotFoundError on 404", async () => { diff --git a/src/adapters/issue-tracker/jira.ts b/src/adapters/issue-tracker/jira.ts index 659e9ba..d3ce023 100644 --- a/src/adapters/issue-tracker/jira.ts +++ b/src/adapters/issue-tracker/jira.ts @@ -1,6 +1,7 @@ import { IssueTrackerNotFoundError, type IssueTrackerAdapter, + type TicketAttachment, type TicketContent, type TicketComment, } from "./types.js"; @@ -14,10 +15,12 @@ export interface JiraConfig { export class JiraAdapter implements IssueTrackerAdapter { private baseUrl: string; + private jiraBaseOrigin: string; private authHeader: string; constructor(private config: JiraConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); + this.jiraBaseOrigin = new URL(this.baseUrl).origin; this.authHeader = "Basic " + Buffer.from(`${config.email}:${config.apiToken}`).toString("base64"); @@ -48,7 +51,7 @@ export class JiraAdapter implements IssueTrackerAdapter { async fetchTicket(id: string): Promise { const data = await this.request( - `/rest/api/3/issue/${id}?fields=summary,description,comment,labels,status,project`, + `/rest/api/3/issue/${id}?fields=summary,description,comment,labels,status,project,attachment`, ); return { id: data.id, @@ -66,6 +69,17 @@ export class JiraAdapter implements IssueTrackerAdapter { ), labels: data.fields.labels ?? [], trackerStatus: data.fields.status?.name ?? "", + attachments: (data.fields.attachment ?? []).map((a: any): TicketAttachment => { + const contentUrl = + a.content == null ? undefined : String(a.content).trim(); + return { + id: String(a.id), + filename: a.filename ?? "", + mimeType: a.mimeType ?? "application/octet-stream", + size: sanitizeAttachmentSize(a.size), + contentUrl: contentUrl || undefined, + }; + }), }; } @@ -103,6 +117,60 @@ export class JiraAdapter implements IssueTrackerAdapter { }); } + async downloadAttachment( + url: string, + opts: { timeoutMs?: number } = {}, + ): Promise { + const timeoutMs = opts.timeoutMs ?? 30_000; + const signal = AbortSignal.timeout(timeoutMs); + const redirectStatuses = new Set([301, 302, 303, 307, 308]); + const maxRedirects = 5; + if (!url || url.trim() === "") { + throw new Error("Jira attachment error: missing attachment content URL"); + } + let currentUrl = new URL(url, this.baseUrl).toString(); + + for (let redirects = 0; redirects <= maxRedirects; redirects++) { + const res = await fetch(currentUrl, { + method: "GET", + headers: this.buildAttachmentHeaders(currentUrl), + redirect: "manual", + signal, + }); + + if (redirectStatuses.has(res.status)) { + const location = res.headers.get("location"); + if (!location) { + await res.body?.cancel?.(); + throw new Error( + `Jira attachment redirect (${res.status}) missing Location header for ${currentUrl}`, + ); + } + // Drain redirect response body to release the socket back to the pool. + await res.body?.cancel?.(); + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + + if (!res.ok) { + await res.body?.cancel?.(); + throw new Error( + `Jira attachment error: status ${res.status} ${res.statusText} on ${currentUrl}`, + ); + } + return Buffer.from(await res.arrayBuffer()); + } + + throw new Error( + `Jira attachment error: too many redirects while fetching ${url}`, + ); + } + + private buildAttachmentHeaders(url: string): HeadersInit | undefined { + if (new URL(url).origin !== this.jiraBaseOrigin) return undefined; + return { Authorization: this.authHeader }; + } + async searchTickets(jql: string): Promise { const data = await this.request( `/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&fields=key&maxResults=50`, @@ -133,3 +201,10 @@ function extractProjectKey(identifier: string): string | undefined { if (dash <= 0) return undefined; return identifier.slice(0, dash).toUpperCase(); } + +function sanitizeAttachmentSize(size: unknown): number { + const parsed = Number(size ?? 0); + if (!Number.isFinite(parsed)) return 0; + if (parsed <= 0) return 0; + return Math.trunc(parsed); +} diff --git a/src/adapters/issue-tracker/types.ts b/src/adapters/issue-tracker/types.ts index 9299a5d..a1a2556 100644 --- a/src/adapters/issue-tracker/types.ts +++ b/src/adapters/issue-tracker/types.ts @@ -8,6 +8,7 @@ export interface TicketContent { comments: TicketComment[]; labels: string[]; trackerStatus: string; + attachments: TicketAttachment[]; } export class IssueTrackerNotFoundError extends Error { @@ -25,6 +26,14 @@ export interface TicketComment { createdAt: string; } +export interface TicketAttachment { + id: string; + filename: string; + mimeType: string; + size: number; + contentUrl?: string; +} + export interface IssueTrackerAdapter { /** * Fetch a single ticket by key/id. @@ -34,4 +43,9 @@ export interface IssueTrackerAdapter { moveTicket(id: string, column: string): Promise; postComment(id: string, comment: string): Promise; searchTickets(query: string): Promise; + /** + * Download an attachment by URL. Optional — not all issue trackers support this. + * Implementations should handle auth and redirects (e.g. signed CDN URLs) internally. + */ + downloadAttachment?(url: string, opts?: { timeoutMs?: number }): Promise; } diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index e025e5c..fc27fbe 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -41,6 +41,7 @@ function makeTicket(overrides: Partial = {}): TicketContent { comments: [], labels: [], trackerStatus: "AI", + attachments: [], ...overrides, }; } @@ -102,9 +103,15 @@ function makeAdapters( describe("dispatchTicket", () => { beforeEach(() => { - vi.clearAllMocks(); + mockSandboxList.mockReset(); + mockStart.mockReset(); + mockGetRun.mockReset(); + mockStopTicketSandboxes.mockReset(); mockSandboxList.mockResolvedValue({ - json: { sandboxes: [] }, + json: { + sandboxes: [], + pagination: { count: 0, next: null, prev: null }, + }, }); mockStart.mockResolvedValue({ runId: "run_123" }); mockStopTicketSandboxes.mockResolvedValue(0); @@ -182,6 +189,7 @@ describe("dispatchTicket", () => { { status: "running" }, { status: "running" }, ], + pagination: { count: 3, next: null, prev: null }, }, }); const adapters = makeAdapters(); @@ -194,6 +202,53 @@ describe("dispatchTicket", () => { expect(mockStart).not.toHaveBeenCalled(); }); + it("paginates sandbox list when counting active sandboxes", async () => { + mockSandboxList + .mockResolvedValueOnce({ + json: { + sandboxes: [{ status: "running" }], + pagination: { count: 1, next: 123, prev: null }, + }, + }) + .mockResolvedValueOnce({ + json: { + sandboxes: [{ status: "running" }, { status: "running" }], + pagination: { count: 2, next: null, prev: 123 }, + }, + }); + const adapters = makeAdapters(); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 3); + + expect(result).toEqual({ started: false, reason: "at_capacity" }); + expect(mockSandboxList).toHaveBeenCalledTimes(2); + expect(mockSandboxList.mock.calls[0][0]).toMatchObject({ + limit: 100, + since: undefined, + signal: expect.any(AbortSignal), + }); + expect(mockSandboxList.mock.calls[1][0]).toMatchObject({ + limit: 100, + since: 123, + signal: expect.any(AbortSignal), + }); + expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); + expect(mockStart).not.toHaveBeenCalled(); + }); + + it("fails closed when sandbox count check fails", async () => { + mockSandboxList.mockRejectedValue(new Error("sandbox list timeout")); + const adapters = makeAdapters(); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 5); + + expect(result).toEqual({ started: false, reason: "at_capacity" }); + expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); + expect(mockStart).not.toHaveBeenCalled(); + }); + it("aborts workflow if claim was removed during dispatch", async () => { const mockCancel = vi.fn().mockResolvedValue(undefined); mockGetRun.mockReturnValue({ cancel: mockCancel }); @@ -275,8 +330,16 @@ describe("dispatchTicket", () => { describe("failed-ticket safeguard full loop", () => { beforeEach(() => { - vi.clearAllMocks(); - mockSandboxList.mockResolvedValue({ json: { sandboxes: [] } }); + mockSandboxList.mockReset(); + mockStart.mockReset(); + mockGetRun.mockReset(); + mockStopTicketSandboxes.mockReset(); + mockSandboxList.mockResolvedValue({ + json: { + sandboxes: [], + pagination: { count: 0, next: null, prev: null }, + }, + }); mockStart.mockResolvedValue({ runId: "run_123" }); mockStopTicketSandboxes.mockResolvedValue(0); }); diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index bed33ee..84d6b16 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -1,4 +1,5 @@ import { start, getRun } from "workflow/api"; +import { Sandbox } from "@vercel/sandbox"; import { env } from "../../env.js"; import { agentWorkflow } from "../workflows/agent.js"; import { logger } from "./logger.js"; @@ -6,6 +7,9 @@ import type { Adapters } from "./adapters.js"; import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; const CLAIMING_PREFIX = "claiming:"; +const SANDBOX_LIST_TIMEOUT_MS = 1_000; +const SANDBOX_LIST_PAGE_LIMIT = 100; +const SANDBOX_COUNT_FAILED = Number.MAX_SAFE_INTEGER; export function isClaimingSentinel(runId: string): boolean { return runId.startsWith(CLAIMING_PREFIX); @@ -109,6 +113,10 @@ export async function dispatchTicket( async function isAtCapacity(max: number): Promise { const active = await getActiveSandboxCount(); + if (active === SANDBOX_COUNT_FAILED) { + logger.warn({ max }, "dispatch_capacity_check_failed_closed"); + return true; + } if (active < max) return false; logger.info({ active, max }, "dispatch_at_capacity"); @@ -117,12 +125,30 @@ async function isAtCapacity(max: number): Promise { async function getActiveSandboxCount(): Promise { try { - const { Sandbox } = await import("@vercel/sandbox"); - const { json } = await Sandbox.list({ limit: 100 }); - return json.sandboxes.filter((s: any) => s.status === "running").length; + let runningCount = 0; + let since: number | undefined; + + while (true) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SANDBOX_LIST_TIMEOUT_MS); + try { + const { json } = await Sandbox.list({ + limit: SANDBOX_LIST_PAGE_LIMIT, + since, + signal: controller.signal, + }); + runningCount += json.sandboxes.filter( + (sandbox: { status?: string }) => sandbox.status === "running", + ).length; + if (json.pagination.next == null) return runningCount; + since = json.pagination.next; + } finally { + clearTimeout(timeout); + } + } } catch (err) { logger.warn({ error: (err as Error).message }, "sandbox_count_check_failed"); - return 0; + return SANDBOX_COUNT_FAILED; } } diff --git a/src/routes/cron/poll.get.ts b/src/routes/cron/poll.get.ts index 1e46e52..0fd4b88 100644 --- a/src/routes/cron/poll.get.ts +++ b/src/routes/cron/poll.get.ts @@ -71,7 +71,16 @@ async function dispatchDiscoveredTickets( const started: string[] = []; for (const key of ticketKeys) { - const result = await dispatchTicket(key, adapters, env.MAX_CONCURRENT_AGENTS); + let result: Awaited>; + try { + result = await dispatchTicket(key, adapters, env.MAX_CONCURRENT_AGENTS); + } catch (err) { + logger.warn( + { ticketKey: key, error: err }, + "poll_dispatch_failed", + ); + break; + } if (result.started) started.push(key); if (result.reason === "at_capacity") break; } diff --git a/src/sandbox/attachments.integration.test.ts b/src/sandbox/attachments.integration.test.ts new file mode 100644 index 0000000..51a59f4 --- /dev/null +++ b/src/sandbox/attachments.integration.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { fetchAttachmentsWithRetry, type AttachmentCaps } from "./attachments.js"; +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; + +describe("attachments → sandbox writeFiles shape", () => { + it("produces writeFiles payloads at /tmp/attachments/", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])) + .mockResolvedValueOnce(Buffer.from("{\"ok\":true}")), + }; + + const attachments: TicketAttachment[] = [ + { + id: "1", + filename: "mockup.png", + mimeType: "image/png", + size: 4, + contentUrl: "https://jira.example/1", + }, + { + id: "2", + filename: "sample.json", + mimeType: "application/json", + size: 11, + contentUrl: "https://jira.example/2", + }, + ]; + + const caps: AttachmentCaps = { + maxFileSizeBytes: 1_000_000, + maxTotalSizeBytes: 10_000_000, + maxCount: 10, + downloadTimeoutMs: 5_000, + }; + + const downloaded = await fetchAttachmentsWithRetry( + downloader, + attachments, + caps, + { info: vi.fn(), warn: vi.fn() }, + ); + + // Simulate the writeAttachments step's payload mapping. + const payload = downloaded + .filter((a) => a.content && !a.failed) + .map((a) => ({ + path: `/tmp/attachments/${a.filename}`, + content: a.content!, + })); + + expect(payload).toHaveLength(2); + expect(payload[0].path).toBe("/tmp/attachments/mockup.png"); + expect(payload[0].content).toBeInstanceOf(Buffer); + expect(payload[1].path).toBe("/tmp/attachments/sample.json"); + }); +}); diff --git a/src/sandbox/attachments.test.ts b/src/sandbox/attachments.test.ts new file mode 100644 index 0000000..9303626 --- /dev/null +++ b/src/sandbox/attachments.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi } from "vitest"; +import { + sanitizeFilename, + formatBytes, + formatAttachmentsIndex, + fetchAttachmentsWithRetry, + type DownloadedAttachment, + type AttachmentCaps, +} from "./attachments.js"; +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; + +describe("sanitizeFilename", () => { + it("preserves simple names", () => { + expect(sanitizeFilename("mockup.png", "att-1")).toBe("mockup.png"); + }); + + it("strips path separators", () => { + expect(sanitizeFilename("a/b/c.png", "att-1")).toBe("abc.png"); + expect(sanitizeFilename("a\\b\\c.png", "att-1")).toBe("abc.png"); + }); + + it("strips null bytes", () => { + expect(sanitizeFilename("a\u0000b.png", "att-1")).toBe("ab.png"); + }); + + it("strips leading dots (no hidden files)", () => { + expect(sanitizeFilename(".env", "att-1")).toBe("env"); + expect(sanitizeFilename("...weird", "att-1")).toBe("weird"); + }); + + it("falls back to attachment- when result is empty", () => { + expect(sanitizeFilename("", "att-9")).toBe("attachment-att-9"); + expect(sanitizeFilename("///", "att-9")).toBe("attachment-att-9"); + expect(sanitizeFilename("....", "att-9")).toBe("attachment-att-9"); + }); + + it("does NOT invoke fallback when stripping leaves a non-empty extension-only name", () => { + expect(sanitizeFilename(".pdf", "att-9")).toBe("pdf"); + expect(sanitizeFilename("/.png", "att-9")).toBe("png"); + }); +}); + +describe("formatBytes", () => { + it("formats bytes under 1KB as B", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + + it("formats KB with no decimals for whole numbers", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(2048)).toBe("2 KB"); + }); + + it("formats KB with one decimal for fractions", () => { + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(348_192)).toBe("340 KB"); + }); + + it("formats MB with one decimal", () => { + expect(formatBytes(1_258_291)).toBe("1.2 MB"); + expect(formatBytes(10 * 1024 * 1024)).toBe("10 MB"); + }); +}); + +describe("formatAttachmentsIndex", () => { + const ok = (filename: string, mimeType: string, size: number): DownloadedAttachment => ({ + filename, + originalFilename: filename, + mimeType, + size, + content: Buffer.from([]), + }); + const fail = ( + filename: string, + reason: string, + attempts = 1, + ): DownloadedAttachment => ({ + filename, + originalFilename: filename, + mimeType: "application/octet-stream", + size: 0, + failed: { reason, attempts }, + }); + + it("returns empty string when no attachments", () => { + expect(formatAttachmentsIndex([])).toBe(""); + }); + + it("lists successful downloads with path and size", () => { + const out = formatAttachmentsIndex([ + ok("mockup.png", "image/png", 348_192), + ok("api-sample.json", "application/json", 2048), + ]); + expect(out).toContain("## Attachments"); + expect(out).toContain("/tmp/attachments/"); + expect(out).toContain("`/tmp/attachments/mockup.png` — image/png, 340 KB"); + expect(out).toContain("`/tmp/attachments/api-sample.json` — application/json, 2 KB"); + }); + + it("marks failed downloads with a warning prefix and reason", () => { + const out = formatAttachmentsIndex([ + fail("spec.pdf", "HTTP 500", 3), + ]); + expect(out).toContain("⚠️"); + expect(out).toContain("spec.pdf"); + expect(out).toContain("failed to download after 3 attempts"); + expect(out).toContain("HTTP 500"); + }); + + it("renders a mix of success and failure", () => { + const out = formatAttachmentsIndex([ + ok("mockup.png", "image/png", 340_000), + fail("broken.bin", "HTTP 404", 1), + ]); + expect(out).toContain("mockup.png"); + expect(out).toContain("⚠️"); + expect(out).toContain("broken.bin"); + }); + + it("renders the section even when all entries failed", () => { + const out = formatAttachmentsIndex([ + fail("a.pdf", "HTTP 500", 3), + fail("b.pdf", "HTTP 500", 3), + ]); + expect(out).toContain("## Attachments"); + expect(out.match(/⚠️/g)?.length).toBe(2); + }); +}); + +const defaultCaps: AttachmentCaps = { + maxFileSizeBytes: 25 * 1024 * 1024, + maxTotalSizeBytes: 100 * 1024 * 1024, + maxCount: 20, + downloadTimeoutMs: 30_000, +}; + +function noopLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +function meta( + id: string, + filename: string, + size: number, + mimeType = "application/octet-stream", +): TicketAttachment { + return { + id, + filename, + mimeType, + size, + contentUrl: `https://jira.example/attachment/${id}`, + }; +} + +describe("fetchAttachmentsWithRetry", () => { + it("downloads all attachments when under caps", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([1, 2, 3])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.png", 3), meta("2", "b.png", 3)], + defaultCaps, + noopLogger(), + ); + expect(out).toHaveLength(2); + expect(out[0].content).toBeInstanceOf(Buffer); + expect(out[0].failed).toBeUndefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("skips attachments without content URL", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([1, 2, 3])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [ + { + id: "1", + filename: "missing.bin", + mimeType: "application/octet-stream", + size: 3, + }, + meta("2", "present.bin", 3), + ], + defaultCaps, + noopLogger(), + ); + expect(out).toHaveLength(2); + expect(out[0].failed?.reason).toMatch(/missing content url/); + expect(out[1].failed).toBeUndefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + expect(downloader.downloadAttachment).toHaveBeenCalledWith( + "https://jira.example/attachment/2", + { timeoutMs: defaultCaps.downloadTimeoutMs }, + ); + }); + + it("skips attachments over per-file cap without downloading", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxFileSizeBytes: 100 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "small.bin", 50), meta("2", "big.bin", 10_000)], + caps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(out[1].failed?.reason).toMatch(/per-file size cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("stops downloading once total cap is exceeded", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxTotalSizeBytes: 150 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [ + meta("1", "a.bin", 100), + meta("2", "b.bin", 100), // 100+100 = 200 > 150 → skipped + meta("3", "c.bin", 40), + ], + caps, + noopLogger(), + ); + expect(out[0].failed).toBeUndefined(); + expect(out[1].failed?.reason).toMatch(/total size cap/); + expect(out[2].failed?.reason).toMatch(/total size cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("skips attachments beyond count cap", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([])), + }; + const caps: AttachmentCaps = { ...defaultCaps, maxCount: 2 }; + const out = await fetchAttachmentsWithRetry( + downloader, + [ + meta("1", "a.bin", 10), + meta("2", "b.bin", 10), + meta("3", "c.bin", 10), + ], + caps, + noopLogger(), + ); + expect(out).toHaveLength(3); + expect(out[0].failed).toBeUndefined(); + expect(out[1].failed).toBeUndefined(); + expect(out[2].failed?.reason).toMatch(/count cap/); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("retries transient 5xx up to 3 times then marks failed", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValue(new Error("Jira attachment error: status 500 Internal Server Error on url")), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 10)], + defaultCaps, + noopLogger(), + ); + expect(out[0].failed).toBeDefined(); + expect(out[0].failed?.attempts).toBe(3); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 404", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValue(new Error("Jira attachment error: status 404 Not Found on url")), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 10)], + defaultCaps, + noopLogger(), + ); + expect(out[0].failed).toBeDefined(); + expect(out[0].failed?.attempts).toBe(1); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("succeeds on second attempt after transient failure", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValueOnce(new Error("Jira attachment error: status 503 Service Unavailable on url")) + .mockResolvedValueOnce(Buffer.from([9])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(out[0].failed).toBeUndefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); + + it("does not treat a bare 5xx sequence in a URL as retryable", async () => { + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValue(new Error("fetch failed on https://example.test/path/500/resource")), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 10)], + defaultCaps, + noopLogger(), + ); + expect(out[0].failed).toBeDefined(); + expect(out[0].failed?.attempts).toBe(1); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(1); + }); + + it("resolves collisions by appending -{id} before the extension", async () => { + const downloader = { + downloadAttachment: vi.fn(async () => Buffer.from([1])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "report.pdf", 1), meta("2", "report.pdf", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].filename).toBe("report.pdf"); + expect(out[1].filename).toBe("report-2.pdf"); + expect(out[1].originalFilename).toBe("report.pdf"); + }); + + it("retries on network abort errors", async () => { + const abortErr = Object.assign(new Error("The operation was aborted"), { + name: "AbortError", + }); + const downloader = { + downloadAttachment: vi + .fn() + .mockRejectedValueOnce(abortErr) + .mockResolvedValueOnce(Buffer.from([1])), + }; + const out = await fetchAttachmentsWithRetry( + downloader, + [meta("1", "a.bin", 1)], + defaultCaps, + noopLogger(), + ); + expect(out[0].content).toBeDefined(); + expect(downloader.downloadAttachment).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/sandbox/attachments.ts b/src/sandbox/attachments.ts new file mode 100644 index 0000000..f0f24fe --- /dev/null +++ b/src/sandbox/attachments.ts @@ -0,0 +1,238 @@ +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; + +export interface DownloadedAttachment { + filename: string; + originalFilename: string; + mimeType: string; + size: number; + content?: Buffer; + failed?: { reason: string; attempts: number }; +} + +export interface AttachmentCaps { + maxFileSizeBytes: number; + maxTotalSizeBytes: number; + maxCount: number; + downloadTimeoutMs: number; +} + +export function sanitizeFilename(name: string, id: string): string { + // Strip path separators, null bytes, and leading dots (no hidden files). + const cleaned = (name ?? "") + .replace(/[\\/]/g, "") + .replace(/\u0000/g, "") + .replace(/^\.+/, ""); + + // Fallback to `attachment-{id}` only when the result is empty, per spec. + // An extension-only input like ".pdf" legitimately sanitizes to "pdf" and does + // NOT trigger the fallback. + return cleaned.length > 0 ? cleaned : `attachment-${id}`; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const kb = n / 1024; + if (kb < 1024) { + return Number.isInteger(kb) ? `${kb} KB` : `${roundOne(kb)} KB`; + } + const mb = kb / 1024; + return Number.isInteger(mb) ? `${mb} MB` : `${roundOne(mb)} MB`; +} + +function roundOne(x: number): string { + // One decimal, but drop trailing ".0" (e.g. 340.0 -> "340"). + const rounded = Math.round(x * 10) / 10; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); +} + +export function formatAttachmentsIndex( + attachments: DownloadedAttachment[], +): string { + if (attachments.length === 0) return ""; + + const lines: string[] = []; + lines.push("## Attachments"); + lines.push(""); + lines.push( + "The following files from the Jira ticket are available in `/tmp/attachments/`.", + ); + lines.push("Read them when relevant to the task."); + lines.push(""); + + for (const a of attachments) { + if (a.failed) { + lines.push( + `- ⚠️ \`${a.originalFilename}\` — failed to download after ${a.failed.attempts} attempt${a.failed.attempts === 1 ? "" : "s"} (${a.failed.reason})`, + ); + } else { + lines.push( + `- \`/tmp/attachments/${a.filename}\` — ${a.mimeType}, ${formatBytes(a.size)}`, + ); + } + } + + return lines.join("\n"); +} + +// MAX_ATTEMPTS = 3 means at most 2 sleeps between 3 tries. The spec phrases this +// as "500 → 2000 → 5000ms" but with only 3 attempts the 5000ms delay never fires +// (the 3rd failure exits the loop). We encode just the two delays that actually +// run to avoid confusing dead-code. +const MAX_ATTEMPTS = 3; +const BACKOFFS_MS = [500, 2000]; + +interface Downloader { + downloadAttachment(url: string, opts?: { timeoutMs?: number }): Promise; +} + +interface AttachmentsLogger { + info: (obj: unknown, msg?: string) => void; + warn: (obj: unknown, msg?: string) => void; +} + +export async function fetchAttachmentsWithRetry( + downloader: Downloader, + attachments: TicketAttachment[], + caps: AttachmentCaps, + log: AttachmentsLogger, +): Promise { + const result: DownloadedAttachment[] = []; + const usedFilenames = new Set(); + let bytesCommitted = 0; + let totalCapTripped = false; + + for (let i = 0; i < attachments.length; i++) { + const att = attachments[i]; + + // Cap: count + if (i >= caps.maxCount) { + result.push(skip(att, "skipped: count cap", log)); + continue; + } + // Cap: per-file size + if (att.size > caps.maxFileSizeBytes) { + result.push(skip(att, "skipped: per-file size cap", log)); + continue; + } + // Cap: total size — once exceeded, all remaining are skipped. + if (totalCapTripped || bytesCommitted + att.size > caps.maxTotalSizeBytes) { + totalCapTripped = true; + result.push(skip(att, "skipped: total size cap", log)); + continue; + } + const contentUrl = att.contentUrl?.trim(); + if (!contentUrl) { + result.push(skip(att, "skipped: missing content url", log)); + continue; + } + + const safeName = resolveFilename(att, usedFilenames); + usedFilenames.add(safeName); + + let attempts = 0; + let lastError: Error | undefined; + while (attempts < MAX_ATTEMPTS) { + attempts++; + try { + const content = await downloader.downloadAttachment(contentUrl, { + timeoutMs: caps.downloadTimeoutMs, + }); + bytesCommitted += att.size; + log.info( + { + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + attempts, + }, + "attachment downloaded", + ); + result.push({ + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + content, + }); + lastError = undefined; + break; + } catch (err) { + lastError = err as Error; + if (!isRetryable(lastError) || attempts >= MAX_ATTEMPTS) break; + // Known simplification: we do not honor `Retry-After` on 429 responses. + // The `Downloader` interface returns only Buffer, so response headers are + // not surfaced. Static backoff is sufficient for v1; revisit if Atlassian + // rate-limiting causes repeated retry storms. + const delay = Math.min(BACKOFFS_MS[attempts - 1] ?? 5000, 10_000); + await new Promise((r) => setTimeout(r, delay)); + } + } + + if (lastError) { + log.warn( + { + filename: att.filename, + reason: lastError.message, + attempts, + }, + "attachment failed", + ); + result.push({ + filename: safeName, + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + failed: { reason: shortReason(lastError.message), attempts }, + }); + } + } + + return result; +} + +function skip( + att: TicketAttachment, + reason: string, + log: AttachmentsLogger, +): DownloadedAttachment { + log.warn({ filename: att.filename, reason }, "attachment skipped"); + return { + filename: sanitizeFilename(att.filename, att.id), + originalFilename: att.filename, + mimeType: att.mimeType, + size: att.size, + failed: { reason, attempts: 0 }, + }; +} + +function resolveFilename( + att: TicketAttachment, + used: Set, +): string { + const safe = sanitizeFilename(att.filename, att.id); + if (!used.has(safe)) return safe; + const dot = safe.lastIndexOf("."); + if (dot <= 0) return `${safe}-${att.id}`; + return `${safe.slice(0, dot)}-${att.id}${safe.slice(dot)}`; +} + +function isRetryable(err: Error): boolean { + const msg = err.message ?? ""; + const status5xxPattern = + /\b(?:status(?:Code)?\s*[:=]?\s*5\d\d|HTTP\/\d(?:\.\d)?\s+5\d\d)\b/i; + const status429Pattern = + /\b(?:status(?:Code)?\s*[:=]?\s*429|HTTP\/\d(?:\.\d)?\s+429)\b/i; + if (err.name === "AbortError") return true; + if (/ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN/i.test(msg)) return true; + if (status5xxPattern.test(msg)) return true; + if (status429Pattern.test(msg)) return true; + return false; +} + +function shortReason(msg: string): string { + // Strip the URL from thrown messages for cleaner index output. + const m = msg.match(/\b(\d{3})\b(.*?)(?: on https?:\/\/.*)?$/); + if (m) return `HTTP ${m[1]}${m[2] ?? ""}`.trim(); + return msg; +} diff --git a/src/sandbox/context.test.ts b/src/sandbox/context.test.ts index cc99f07..96bca01 100644 --- a/src/sandbox/context.test.ts +++ b/src/sandbox/context.test.ts @@ -55,6 +55,74 @@ describe("assembleResearchPlanContext", () => { expect(result).toContain("### Failed: test"); expect(result).toContain("## Merge Conflicts"); }); + + it("renders attachments index when attachments are provided", () => { + const result = assembleResearchPlanContext({ + ticket: { + identifier: "TEST-3", + title: "With files", + description: "desc", + acceptanceCriteria: "ac", + comments: [], + }, + prompt: "prompt", + branchName: "blazebot/test-3", + attachments: [ + { + filename: "mockup.png", + originalFilename: "mockup.png", + mimeType: "image/png", + size: 348_192, + content: Buffer.from([]), + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("/tmp/attachments/mockup.png"); + expect(result).toContain("image/png"); + + const atIdx = result.indexOf("## Attachments"); + const descIdx = result.indexOf("## Description"); + expect(atIdx).toBeGreaterThan(-1); + expect(descIdx).toBeGreaterThan(atIdx); + }); + + it("omits attachments section when list is empty or absent", () => { + const withoutField = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + }); + expect(withoutField).not.toContain("## Attachments"); + + const withEmpty = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + attachments: [], + }); + expect(withEmpty).not.toContain("## Attachments"); + }); + + it("shows failed attachments in the index even when no bytes downloaded", () => { + const result = assembleResearchPlanContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + branchName: "b", + attachments: [ + { + filename: "spec.pdf", + originalFilename: "spec.pdf", + mimeType: "application/pdf", + size: 0, + failed: { reason: "HTTP 500", attempts: 3 }, + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("⚠️"); + expect(result).toContain("spec.pdf"); + }); }); describe("assembleImplementationContext (new)", () => { @@ -78,6 +146,73 @@ describe("assembleImplementationContext (new)", () => { expect(result).toContain("Create LoginForm component"); expect(result).toContain("You are an implementation agent..."); }); + + it("renders attachments index when attachments are provided", () => { + const result = assembleImplementationContext({ + ticket: { + identifier: "TEST-3", + title: "With files", + description: "desc", + acceptanceCriteria: "ac", + comments: [], + }, + prompt: "prompt", + researchPlanMarkdown: "plan", + attachments: [ + { + filename: "mockup.png", + originalFilename: "mockup.png", + mimeType: "image/png", + size: 348_192, + content: Buffer.from([]), + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("/tmp/attachments/mockup.png"); + + const atIdx = result.indexOf("## Attachments"); + const acIdx = result.indexOf("## Acceptance Criteria"); + expect(atIdx).toBeGreaterThan(-1); + expect(acIdx).toBeGreaterThan(atIdx); + }); + + it("omits attachments section when list is empty or absent", () => { + const withoutField = assembleImplementationContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + }); + expect(withoutField).not.toContain("## Attachments"); + + const withEmpty = assembleImplementationContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + attachments: [], + }); + expect(withEmpty).not.toContain("## Attachments"); + }); + + it("shows failed attachments in the index even when no bytes downloaded", () => { + const result = assembleImplementationContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + attachments: [ + { + filename: "spec.pdf", + originalFilename: "spec.pdf", + mimeType: "application/pdf", + size: 0, + failed: { reason: "HTTP 500", attempts: 3 }, + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("⚠️"); + expect(result).toContain("spec.pdf"); + }); }); describe("assembleImplementationRetryContext", () => { @@ -109,6 +244,77 @@ describe("assembleImplementationRetryContext", () => { expect(result).toContain("No null check"); expect(result).toContain("critical"); }); + + it("renders attachments index when attachments are provided", () => { + const result = assembleImplementationRetryContext({ + ticket: { + identifier: "TEST-3", + title: "With files", + description: "desc", + acceptanceCriteria: "ac", + comments: [], + }, + prompt: "prompt", + researchPlanMarkdown: "plan", + reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, + attachments: [ + { + filename: "mockup.png", + originalFilename: "mockup.png", + mimeType: "image/png", + size: 348_192, + content: Buffer.from([]), + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("/tmp/attachments/mockup.png"); + + const atIdx = result.indexOf("## Attachments"); + const acIdx = result.indexOf("## Acceptance Criteria"); + expect(atIdx).toBeGreaterThan(-1); + expect(acIdx).toBeGreaterThan(atIdx); + }); + + it("omits attachments section when list is empty or absent", () => { + const withoutField = assembleImplementationRetryContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, + }); + expect(withoutField).not.toContain("## Attachments"); + + const withEmpty = assembleImplementationRetryContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, + attachments: [], + }); + expect(withEmpty).not.toContain("## Attachments"); + }); + + it("shows failed attachments in the index even when no bytes downloaded", () => { + const result = assembleImplementationRetryContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, + attachments: [ + { + filename: "spec.pdf", + originalFilename: "spec.pdf", + mimeType: "application/pdf", + size: 0, + failed: { reason: "HTTP 500", attempts: 3 }, + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("⚠️"); + expect(result).toContain("spec.pdf"); + }); }); describe("assembleReviewContext", () => { @@ -131,6 +337,77 @@ describe("assembleReviewContext", () => { expect(result).toContain("+export function LoginForm()"); expect(result).toContain("You are a review agent..."); }); + + it("renders attachments index when attachments are provided", () => { + const result = assembleReviewContext({ + ticket: { + identifier: "TEST-3", + title: "With files", + description: "desc", + acceptanceCriteria: "ac", + comments: [], + }, + prompt: "prompt", + researchPlanMarkdown: "plan", + gitDiff: "diff", + attachments: [ + { + filename: "mockup.png", + originalFilename: "mockup.png", + mimeType: "image/png", + size: 348_192, + content: Buffer.from([]), + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("/tmp/attachments/mockup.png"); + + const atIdx = result.indexOf("## Attachments"); + const acIdx = result.indexOf("## Acceptance Criteria"); + expect(atIdx).toBeGreaterThan(-1); + expect(acIdx).toBeGreaterThan(atIdx); + }); + + it("omits attachments section when list is empty or absent", () => { + const withoutField = assembleReviewContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + gitDiff: "diff", + }); + expect(withoutField).not.toContain("## Attachments"); + + const withEmpty = assembleReviewContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + gitDiff: "diff", + attachments: [], + }); + expect(withEmpty).not.toContain("## Attachments"); + }); + + it("shows failed attachments in the index even when no bytes downloaded", () => { + const result = assembleReviewContext({ + ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, + prompt: "p", + researchPlanMarkdown: "plan", + gitDiff: "diff", + attachments: [ + { + filename: "spec.pdf", + originalFilename: "spec.pdf", + mimeType: "application/pdf", + size: 0, + failed: { reason: "HTTP 500", attempts: 3 }, + }, + ], + }); + expect(result).toContain("## Attachments"); + expect(result).toContain("⚠️"); + expect(result).toContain("spec.pdf"); + }); }); describe("formatCheckResults", () => { diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 06c80f4..6f4783f 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,5 +1,7 @@ import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; import type { ReviewOutput } from "./agent-runner.js"; +import type { DownloadedAttachment } from "./attachments.js"; +import { formatAttachmentsIndex } from "./attachments.js"; interface TicketData { identifier: string; @@ -16,12 +18,14 @@ export interface ResearchPlanContextInput { prComments?: PRComment[]; checkResults?: CheckRunResult[]; hasConflicts?: boolean; + attachments?: DownloadedAttachment[]; } export interface ImplementationContextInput { ticket: TicketData; prompt: string; researchPlanMarkdown: string; + attachments?: DownloadedAttachment[]; } export interface ImplementationRetryContextInput { @@ -29,6 +33,7 @@ export interface ImplementationRetryContextInput { prompt: string; researchPlanMarkdown: string; reviewFeedback: ReviewOutput; + attachments?: DownloadedAttachment[]; } export interface ReviewContextInput { @@ -36,10 +41,12 @@ export interface ReviewContextInput { prompt: string; researchPlanMarkdown: string; gitDiff: string; + attachments?: DownloadedAttachment[]; } export function assembleResearchPlanContext(input: ResearchPlanContextInput): string { - const { ticket, prompt, branchName, prComments, checkResults, hasConflicts } = input; + const { ticket, prompt, branchName, prComments, checkResults, hasConflicts, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); let md = `# Requirements @@ -50,7 +57,7 @@ ${ticket.identifier} ## Ticket ${ticket.title} - +${attachmentsSection} ## Description ${ticket.description} @@ -85,7 +92,8 @@ ${branchName} } export function assembleImplementationContext(input: ImplementationContextInput): string { - const { ticket, prompt, researchPlanMarkdown } = input; + const { ticket, prompt, researchPlanMarkdown, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); return `# Requirements ## Ticket ID @@ -95,7 +103,7 @@ ${ticket.identifier} ## Ticket ${ticket.title} - +${attachmentsSection} ## Acceptance Criteria ${ticket.acceptanceCriteria || "None specified."} @@ -111,7 +119,8 @@ ${prompt} } export function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string { - const { ticket, prompt, researchPlanMarkdown, reviewFeedback } = input; + const { ticket, prompt, researchPlanMarkdown, reviewFeedback, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); return `# Requirements ## Ticket ID @@ -121,7 +130,7 @@ ${ticket.identifier} ## Ticket ${ticket.title} - +${attachmentsSection} ## Acceptance Criteria ${ticket.acceptanceCriteria || "None specified."} @@ -145,7 +154,8 @@ ${prompt} } export function assembleReviewContext(input: ReviewContextInput): string { - const { ticket, prompt, researchPlanMarkdown, gitDiff } = input; + const { ticket, prompt, researchPlanMarkdown, gitDiff, attachments } = input; + const attachmentsSection = renderAttachmentsSection(attachments); return `# Requirements ## Ticket ID @@ -155,7 +165,7 @@ ${ticket.identifier} ## Ticket ${ticket.title} - +${attachmentsSection} ## Acceptance Criteria ${ticket.acceptanceCriteria || "None specified."} @@ -242,3 +252,10 @@ export function formatCheckResults(checks: CheckRunResult[]): string { return parts.join("\n\n"); } + +function renderAttachmentsSection( + attachments: DownloadedAttachment[] | undefined, +): string { + if (!attachments || attachments.length === 0) return ""; + return `\n${formatAttachmentsIndex(attachments)}\n`; +} diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index b81b767..a136c0c 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -2,6 +2,8 @@ import { sleep } from "workflow"; import type { AgentOutput } from "../sandbox/agent-runner.js"; import type { ReviewOutput } from "../sandbox/agent-runner.js"; import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; +import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; +import type { DownloadedAttachment } from "../sandbox/attachments.js"; import type { PhaseUsage } from "../sandbox/usage.js"; // --- Step Functions --- @@ -15,6 +17,99 @@ async function fetchAndValidateTicket(ticketId: string, columnAi: string) { return ticket; } +async function fetchAttachments( + ticketIdentifier: string, + attachments: TicketAttachment[], +) { + "use step"; + const { logger } = await import("../lib/logger.js"); + const log = logger.child({ ticket_identifier: ticketIdentifier, step: "fetchAttachments" }); + log.info({ count: attachments.length }, "fetchAttachments: start"); + + if (attachments.length === 0) { + log.info({}, "fetchAttachments: no attachments"); + return []; + } + + const { env } = await import("../../env.js"); + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { fetchAttachmentsWithRetry } = await import("../sandbox/attachments.js"); + const { issueTracker } = createStepAdapters(); + + // downloadAttachment is optional on IssueTrackerAdapter — not all trackers + // support it. If absent, skip attachments cleanly. + if (typeof issueTracker.downloadAttachment !== "function") { + log.warn( + { tracker: issueTracker.constructor.name }, + "issue tracker does not support attachment downloads; skipping", + ); + return []; + } + + const downloader = issueTracker as { + downloadAttachment: (url: string, opts?: { timeoutMs?: number }) => Promise; + }; + + const result = await fetchAttachmentsWithRetry( + downloader, + attachments, + { + maxFileSizeBytes: env.ATTACHMENT_MAX_FILE_SIZE_MB * 1024 * 1024, + maxTotalSizeBytes: env.ATTACHMENT_MAX_TOTAL_SIZE_MB * 1024 * 1024, + maxCount: env.ATTACHMENT_MAX_COUNT, + downloadTimeoutMs: env.ATTACHMENT_DOWNLOAD_TIMEOUT_MS, + }, + log, + ); + log.info( + { + succeeded: result.filter((a) => !a.failed).length, + failed: result.filter((a) => a.failed).length, + }, + "fetchAttachments: done", + ); + return result; +} +fetchAttachments.maxRetries = 0; + +async function writeAttachments( + sandboxId: string, + attachments: DownloadedAttachment[], +): Promise { + "use step"; + const { logger } = await import("../lib/logger.js"); + const log = logger.child({ sandboxId, step: "writeAttachments" }); + + const toWrite = attachments.filter((a) => a.content && !a.failed); + log.info( + { count: toWrite.length, totalReceived: attachments.length }, + "writeAttachments: start", + ); + if (toWrite.length === 0) { + log.info({}, "writeAttachments: nothing to write"); + return; + } + + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + // Ensure target directory exists — writeFiles does not guarantee mkdir -p semantics. + await sandbox.runCommand("mkdir", ["-p", "/tmp/attachments"]); + + await sandbox.writeFiles( + toWrite.map((a) => ({ + path: `/tmp/attachments/${a.filename}`, + content: Buffer.isBuffer(a.content) + ? (a.content as Buffer) + : Buffer.from(a.content as unknown as Uint8Array), + })), + ); + log.info({ count: toWrite.length }, "writeAttachments: done"); +} +writeAttachments.maxRetries = 0; + async function createFeatureBranch(branchName: string, baseBranch: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); @@ -252,10 +347,14 @@ export async function agentWorkflow(ticketId: string) { const mergeBase = prContext?.hasConflicts ? baseBranch : undefined; + const downloadedAttachments = await fetchAttachments(ticket.identifier, ticket.attachments); + // Provision sandbox once for all phases const sandboxId = await provisionSandbox(branchName, mergeBase); try { + await writeAttachments(sandboxId, downloadedAttachments); + // ========== PHASE 1: Research & Plan ========== await configureStopHook(sandboxId, false); @@ -274,6 +373,7 @@ export async function agentWorkflow(ticketId: string) { prComments: prContext?.prComments, checkResults: prContext?.checkResults, hasConflicts: prContext?.hasConflicts, + attachments: downloadedAttachments, }); const researchScript = buildPhaseScript({ @@ -339,11 +439,13 @@ export async function agentWorkflow(ticketId: string) { prompt: getPrompt("implement.md"), researchPlanMarkdown, reviewFeedback: lastReviewFeedback, + attachments: downloadedAttachments, }) : assembleImplementationContext({ ticket: ticketData, prompt: getPrompt("implement.md"), researchPlanMarkdown, + attachments: downloadedAttachments, }); const implScript = buildPhaseScript({ @@ -402,6 +504,7 @@ export async function agentWorkflow(ticketId: string) { prompt: getPrompt("review.md"), researchPlanMarkdown, gitDiff, + attachments: downloadedAttachments, }); const reviewScript = buildPhaseScript({ From 667255d113cddc56122be4d15a8b8a9a513a4b0c Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 12:08:28 +0200 Subject: [PATCH 31/71] feat: add logs to dispatch --- src/routes/webhooks/jira.post.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 9dbe03e..06545e7 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -45,8 +45,28 @@ export default defineEventHandler(async (event) => { logger.info({ ticketKey }, "webhook_received"); const adapters = createAdapters(); + const webhookEvent = typeof body?.webhookEvent === "string" ? body.webhookEvent : null; const ticketStatus = extractTicketStatus(body); + logger.info( + { + ticketKey, + webhookEvent, + payloadStatus: ticketStatus, + payloadProjectKey: projectKey, + }, + "webhook_payload_parsed", + ); + + if (!ticketStatus) { + logger.info({ ticketKey }, "webhook_missing_payload_status_dispatching_anyway"); + } + if (ticketStatus && !isAiColumnStatus(ticketStatus)) { + logger.info( + { ticketKey, payloadStatus: ticketStatus, expectedAiStatus: env.COLUMN_AI }, + "webhook_payload_status_outside_ai_column", + ); + const liveTicketState = await getLiveTicketState(ticketKey, adapters.issueTracker); if (liveTicketState.inAiColumn) { logger.info( @@ -59,6 +79,16 @@ export default defineEventHandler(async (event) => { "webhook_skip_cancel_live_ticket_in_ai_column", ); const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + logger.info( + { + ticketKey, + started: result.started, + reason: result.reason, + runId: result.runId, + dispatchContext: "payload_outdated_live_ticket_in_ai", + }, + "webhook_dispatch_result", + ); return { status: result.started ? "dispatched" : "skipped", ticketKey, From 93f4172bf434a153d24092ff4d5b97defdeabf37 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 12:34:00 +0200 Subject: [PATCH 32/71] fix: dispatch timeout --- src/lib/dispatch.test.ts | 13 ++++++++ src/lib/dispatch.ts | 54 ++++++++++++++++++++++---------- src/routes/webhooks/jira.post.ts | 24 ++++++++++++-- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index fc27fbe..5bf34e1 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -326,6 +326,19 @@ describe("dispatchTicket", () => { expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); expect(mockStart).not.toHaveBeenCalled(); }); + + it("returns error when failed-marker precheck throws", async () => { + const adapters = makeAdapters({ + isTicketFailed: vi.fn().mockRejectedValue(new Error("registry unavailable")), + }); + const { dispatchTicket } = await import("./dispatch.js"); + + const result = await dispatchTicket("PROJ-42", adapters, 5); + + expect(result).toEqual({ started: false, reason: "error" }); + expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); + expect(mockStart).not.toHaveBeenCalled(); + }); }); describe("failed-ticket safeguard full loop", () => { diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index 84d6b16..8de94e2 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -31,32 +31,49 @@ export interface DispatchResult { | "wrong_project_key"; } +export interface DispatchOptions { + skipCapacityCheck?: boolean; +} + export async function dispatchTicket( ticketKey: string, adapters: Adapters, maxConcurrentAgents: number, + options: DispatchOptions = {}, ): Promise { const expectedProjectKey = env.JIRA_PROJECT_KEY.trim().toUpperCase(); const expectedAiStatus = env.COLUMN_AI.trim().toLowerCase(); const { issueTracker, runRegistry } = adapters; + let stage = "precheck_failed_marker"; + let claimHeld = false; + let claimValue = ""; + try { + logger.info({ ticketKey, maxConcurrentAgents }, "dispatch_attempt"); - if (await runRegistry.isTicketFailed(ticketKey)) { - logger.info({ ticketKey }, "dispatch_skipped_previously_failed"); - return { started: false, reason: "previously_failed" }; - } + if (await runRegistry.isTicketFailed(ticketKey)) { + logger.info({ ticketKey }, "dispatch_skipped_previously_failed"); + return { started: false, reason: "previously_failed" }; + } - if (await isAtCapacity(maxConcurrentAgents)) { - return { started: false, reason: "at_capacity" }; - } + if (!options.skipCapacityCheck) { + stage = "precheck_capacity"; + if (await isAtCapacity(maxConcurrentAgents)) { + return { started: false, reason: "at_capacity" }; + } + } else { + logger.info({ ticketKey }, "dispatch_capacity_check_skipped"); + } - const claimValue = `${CLAIMING_PREFIX}${Date.now()}`; - const claimed = await runRegistry.claim(ticketKey, claimValue); - if (!claimed) { - logger.info({ ticketKey }, "dispatch_already_claimed"); - return { started: false, reason: "already_claimed" }; - } + stage = "claim_ticket"; + claimValue = `${CLAIMING_PREFIX}${Date.now()}`; + const claimed = await runRegistry.claim(ticketKey, claimValue); + if (!claimed) { + logger.info({ ticketKey }, "dispatch_already_claimed"); + return { started: false, reason: "already_claimed" }; + } + claimHeld = true; - try { + stage = "fetch_ticket"; const ticket = await issueTracker.fetchTicket(ticketKey); const ticketStatus = ticket.trackerStatus.trim().toLowerCase(); if (ticketStatus !== expectedAiStatus) { @@ -83,12 +100,14 @@ export async function dispatchTicket( return { started: false, reason: "wrong_project_key" }; } + stage = "start_workflow"; const handle = await start(agentWorkflow, [ticket.id]); logger.info( { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, "workflow_started", ); + stage = "verify_claim_after_start"; const claimStillHeld = await verifyClaimNotCancelled( ticketKey, claimValue, @@ -99,12 +118,15 @@ export async function dispatchTicket( return { started: false, reason: "already_claimed" }; } + stage = "register_run"; await runRegistry.register(ticketKey, handle.runId); return { started: true, runId: handle.runId }; } catch (err) { - await runRegistry.unregister(ticketKey).catch(() => {}); + if (claimHeld) { + await runRegistry.unregister(ticketKey).catch(() => {}); + } logger.warn( - { ticketKey, error: (err as Error).message }, + { ticketKey, stage, error: (err as Error).message }, "dispatch_error", ); return { started: false, reason: "error" }; diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 06545e7..d8fd141 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -78,7 +78,17 @@ export default defineEventHandler(async (event) => { }, "webhook_skip_cancel_live_ticket_in_ai_column", ); - const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + logger.info( + { + ticketKey, + maxConcurrentAgents: env.MAX_CONCURRENT_AGENTS, + dispatchContext: "payload_outdated_live_ticket_in_ai", + }, + "webhook_dispatch_started", + ); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS, { + skipCapacityCheck: true, + }); logger.info( { ticketKey, @@ -119,7 +129,17 @@ export default defineEventHandler(async (event) => { }; } - const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + logger.info( + { + ticketKey, + maxConcurrentAgents: env.MAX_CONCURRENT_AGENTS, + dispatchContext: "default", + }, + "webhook_dispatch_started", + ); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS, { + skipCapacityCheck: true, + }); logger.info( { ticketKey, started: result.started, reason: result.reason, runId: result.runId }, From 0ccdb992bf7c7b9a13527fd137024b6a5c094c07 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 14:42:40 +0200 Subject: [PATCH 33/71] feat: add new e2e --- docs/user-stories.md | 379 ++++++++++++++++++++ e2e/env.ts | 3 + e2e/helpers/github.ts | 105 ++++++ e2e/helpers/jira.ts | 60 ++++ e2e/helpers/redis.ts | 3 +- e2e/helpers/sandbox.ts | 42 +++ e2e/tier2/implementation-and-review.test.ts | 145 -------- e2e/tier2/us1-clear-ticket-pr.test.ts | 92 +++++ e2e/tier2/us2-attachments.test.ts | 113 ++++++ e2e/tier2/us3-review-fix-cycle.test.ts | 140 ++++++++ e2e/tier2/us4-merge-conflict-rebase.test.ts | 163 +++++++++ 11 files changed, 1099 insertions(+), 146 deletions(-) create mode 100644 docs/user-stories.md create mode 100644 e2e/helpers/sandbox.ts delete mode 100644 e2e/tier2/implementation-and-review.test.ts create mode 100644 e2e/tier2/us1-clear-ticket-pr.test.ts create mode 100644 e2e/tier2/us2-attachments.test.ts create mode 100644 e2e/tier2/us3-review-fix-cycle.test.ts create mode 100644 e2e/tier2/us4-merge-conflict-rebase.test.ts diff --git a/docs/user-stories.md b/docs/user-stories.md new file mode 100644 index 0000000..048d2dd --- /dev/null +++ b/docs/user-stories.md @@ -0,0 +1,379 @@ +# Blazebot User Stories + +Core behavioral stories for Blazebot. Each story has a concrete example and verifiable assertions to serve as a foundation for integration and E2E tests. + +**VCS coverage:** Stories involving VCS operations (branch creation, PR, push, merge conflicts) must be tested against both **GitHub** and **GitLab**. + +--- + +## 1. Happy Path Journeys + +### US-1: Clear ticket produces a PR `[GitHub/GitLab]` + +> As a developer, when I create a ticket with clear requirements and move it to the "AI" column, the agent should implement the feature and create a PR for review. + +**Example ticket:** +``` +Title: Add GET /api/health endpoint +Description: Create a GET /api/health route that returns { status: "ok" } with HTTP 200. +Acceptance criteria: +- Returns JSON { status: "ok" } +- HTTP 200 response +``` + +**Expected behavior:** +1. Ticket discovered in AI column (via cron poll or Jira webhook) +2. Agent creates branch `blazebot/awt-123` +3. Research phase analyzes repo and produces implementation plan +4. Implementation phase creates the endpoint (framework-agnostic) and commits +5. Internal review phase approves the changes +6. Changes pushed to VCS, PR created +7. Ticket moves to "AI Review" column +8. Redis entry cleaned up, sandbox torn down + +**Verifications:** +- PR exists on branch `blazebot/awt-123` +- PR has at least 1 commit +- Ticket status = "AI Review" +- Redis has no entry for ticket +- No sandbox running for this ticket + +--- + +### US-2: Ticket with attachments (integration test: fetch phase only) + +> As a developer, when I attach files to a ticket, the agent should download them and make them available in the sandbox. + +**Example ticket:** +``` +Title: Create user profile card component +Description: Build a profile card component matching the attached mockup. +Attachments: +- profile-mockup.png (120KB, screenshot of desired UI) +- design-tokens.json (2KB, color/spacing values) +``` + +**Test scope:** Integration test for the attachment fetch + write phase only — not a full E2E flow. + +**Expected behavior:** +1. `fetchAttachments()` downloads both files from Jira +2. Files respect size limits (`ATTACHMENT_MAX_FILE_SIZE_MB`, `ATTACHMENT_MAX_TOTAL_SIZE_MB`) +3. `writeAttachments()` writes files to `/tmp/attachments/` in sandbox +4. Files are readable inside the sandbox at expected paths + +**Verifications:** +- `fetchAttachments` returns 2 items with `failed: false` +- Downloaded content matches expected sizes +- Files exist at `/tmp/attachments/profile-mockup.png` and `/tmp/attachments/design-tokens.json` in sandbox +- File contents are valid (not corrupted) + +--- + +### US-3: Review feedback triggers a fix cycle `[GitHub/GitLab]` + +> As a developer, when I leave review comments on the agent's PR and move the ticket back to "AI", the agent should address the feedback and push updates to the same PR. + +**Example:** +``` +Initial PR: Adds GET /api/ping returning { ping: "pong" } +Review comment: "Rename /ping to /healthcheck. Remove the old /ping route entirely." +``` + +**Expected behavior:** +1. Developer adds PR comment and moves ticket back to "AI" +2. Ticket discovered (via cron poll or webhook); agent detects existing PR on branch +3. Agent does NOT reset the branch (preserves existing work) +4. Research phase reads PR comments + check results +5. Implementation phase applies the requested changes +6. Push updates to same branch; no new PR created +7. Ticket moves back to "AI Review" + +**Verifications:** +- Same PR number, no duplicate PR +- PR has more commits than before the review fix +- Old `/ping` route removed, `/healthcheck` exists +- Ticket status = "AI Review" +- Redis cleaned up +- No sandbox running for this ticket + +--- + +### US-4: PR with merge conflicts — agent rebases `[GitHub/GitLab]` + +> As a developer, when my ticket's PR has merge conflicts, moving the ticket back to AI should trigger the agent to resolve conflicts. + +**Example scenario:** +``` +PR for AWT-123 has merge conflicts with main +Developer moves ticket back to AI +``` + +**Expected behavior:** +1. Agent detects `hasConflicts: true` from PR context +2. Sandbox provisioned with `mergeBase` set to base branch +3. Agent resolves conflicts during implementation +4. Push updated branch +5. Ticket moves back to "AI Review" + +**Verifications:** +- PR no longer has merge conflicts after agent push +- PR has new commits +- Ticket status = "AI Review" +- Redis has no entry for ticket +- No sandbox running for this ticket + +--- + +## 2. Clarification & Ambiguity Journeys + +### US-5: Unclear ticket triggers clarification + +> As a developer, when I create a ticket that is too vague, subjective, or ambiguous to implement, the agent should ask clarification questions and move the ticket to Backlog. + +**Example ticket:** +``` +Title: Change website color to my favorite color +Description: Update the primary brand color across the site to my favorite color. +``` + +**Expected behavior:** +1. Research phase identifies the ticket as unclear (vague, subjective, missing details, contradictory, etc.) +2. Agent returns `STATUS: clarification_needed` +3. Numbered clarification questions posted as a Jira comment +4. Ticket moves to "Backlog" column +5. Redis entry cleaned up, sandbox torn down + +**Verifications:** +- Ticket status = "Backlog" +- Jira comment exists with numbered questions (1. ..., 2. ..., etc.) +- No PR created +- Redis has no entry for ticket +- No sandbox running for this ticket + +--- + +### US-6: Clarification answered — ticket re-processed successfully `[GitHub/GitLab]` + +> As a developer, after I answer the agent's clarification questions and move the ticket back to "AI", the agent should use my answers and complete the implementation. + +**Example:** +``` +Original ticket: "Change website color to my favorite color" +Agent asked: "1. What is your favorite color?" +Developer comment: "1. Use #FF6B35 (orange)" +Developer moves ticket back to "AI" +``` + +**Expected behavior:** +1. Agent reads previous session memory from `blazebot/memory/AWT-123.md` +2. Research phase reads Jira comments including the answer +3. Agent implements with the specified color `#FF6B35` +4. PR created with the color change +5. Ticket moves to "AI Review" + +**Verifications:** +- PR diff contains `#FF6B35` or equivalent +- Ticket status = "AI Review" +- Redis has no entry for ticket +- No sandbox running for this ticket + +--- + +## 3. Failure & Recovery + +### US-7: Agent failure moves ticket to Backlog + +> As a developer, when the agent fails for any reason (timeout, error, unresolvable issue), the ticket should be moved to Backlog and resources cleaned up. + +**Failure scenarios (any of these):** +- Research phase times out (exceeds 20-minute limit) +- Implementation phase times out (exceeds 35-minute limit) +- Agent returns `{ result: "failed" }` +- Unhandled exception in the workflow + +**Expected behavior:** +1. Ticket moves to "Backlog" +2. Redis entry cleaned up +3. Sandbox torn down + +**Verifications:** +- Ticket status = "Backlog" +- No PR created +- Redis has no entry for ticket +- No sandbox running for this ticket + +--- + +### US-8: Previously failed ticket is skipped on re-poll + +> As a developer, if a ticket has previously failed, the cron poller should skip it to avoid infinite retry loops — until I move it out of AI and back. + +**Expected behavior:** +1. Ticket AWT-123 fails and is marked as failed in Redis +2. Developer does NOT move the ticket (stays in AI column) +3. Next cron poll: ticket is discovered but skipped (`previously_failed`) +4. No new workflow started + +**Verifications:** +- Dispatch returns `{ started: false, reason: "previously_failed" }` +- No workflow started + +--- + +### US-9: Failed marker cleared when ticket leaves AI + +> As a developer, when I move a previously-failed ticket out of the AI column, the failed marker should be cleared so it can be retried later. + +**Expected behavior:** +1. Ticket AWT-123 is marked as failed in Redis +2. Developer moves ticket from "AI" to "Backlog" (or any non-AI column) +3. Next reconciliation cycle detects ticket is no longer in AI +4. Failed marker cleared from Redis + +**Verifications:** +- Redis failed marker removed for ticket +- If developer moves ticket back to AI later, it will be processed normally + +--- + +## 4. Discovery & Dispatch + +### US-10: Duplicate dispatch prevented by atomic claim + +> As a developer, even if two cron polls overlap or a webhook fires at the same time as a poll, only one workflow should start per ticket. + +**Example scenario:** +``` +Cron poll A discovers AWT-123 at T=0 +Cron poll B discovers AWT-123 at T=0.5s (overlapping) +``` + +**Expected behavior:** +1. First dispatch atomically claims AWT-123 via Redis HSETNX +2. Second dispatch attempt finds ticket already claimed +3. Only one workflow starts + +**Verifications:** +- Dispatch #1: `{ started: true }` +- Dispatch #2: `{ started: false, reason: "already_claimed" }` +- Exactly one workflow running for the ticket + +--- + +### US-11: Capacity limit respected + +> As a developer, the system should not start more sandboxes than the configured `MAX_CONCURRENT_AGENTS` limit. + +**Example scenario:** +``` +MAX_CONCURRENT_AGENTS = 3 +3 sandboxes already running +New ticket AWT-456 moved to AI column +``` + +**Expected behavior:** +1. Cron discovers AWT-456 +2. Capacity check: 3 active sandboxes >= 3 max +3. Dispatch skipped for AWT-456 (`at_capacity`) +4. Ticket remains in AI column, will be picked up when a slot frees + +**Verifications:** +- Dispatch returns `{ started: false, reason: "at_capacity" }` +- No workflow started +- Ticket remains in AI column (not moved) + +--- + +### US-12: Ticket moved out of AI during dispatch + +> As a developer, if I move a ticket out of the AI column while the agent is in the process of claiming it, the dispatch should abort cleanly. + +**Example scenario:** +``` +T=0: Cron discovers AWT-123 in AI column +T=0.1s: Developer moves AWT-123 to "In Progress" +T=0.2s: Dispatch fetches ticket — status is "In Progress" +``` + +**Expected behavior:** +1. Atomic claim succeeds (Redis) +2. Fetch ticket: status is no longer "AI" +3. Claim released in Redis +4. Dispatch returns `not_in_ai_column` + +**Verifications:** +- Dispatch returns `{ started: false, reason: "not_in_ai_column" }` +- Redis claim cleaned up +- No workflow started + +--- + +### US-13: Webhook-triggered immediate dispatch + +> As a developer, when Jira sends a webhook on ticket status change, the agent should start processing immediately without waiting for the next cron poll. + +**Example scenario:** +``` +Developer moves AWT-123 to AI column +Jira fires webhook to /webhooks/jira +``` + +**Expected behavior:** +1. Webhook received with valid HMAC signature +2. Ticket extracted from webhook payload +3. Dispatch triggered immediately +4. Workflow starts within seconds + +**Verifications:** +- Webhook returns 200 +- Workflow started for the ticket +- Processing begins without waiting for next cron cycle + +--- + +## 5. Reconciliation + +### US-14: Stale claim cleaned up + +> As a developer, if a dispatch process crashes after claiming a ticket but before starting a workflow, the stale claim should be cleaned up within 5 minutes. + +**Example scenario:** +``` +T=0: Dispatch claims AWT-123 (Redis entry: "claiming:1713200000000") +T=0.1s: Dispatch process crashes before starting workflow +T=5min: Reconciliation runs +``` + +**Expected behavior:** +1. Reconciliation finds claim older than 5 minutes +2. Stale claim removed from Redis +3. Ticket can be picked up by next poll cycle + +**Verifications:** +- Redis entry removed +- Next dispatch for same ticket succeeds + +--- + +### US-15: Orphaned run cancelled when ticket leaves AI + +> As a developer, if I move a ticket out of the AI column while the agent is still working, the running workflow should be cancelled and cleaned up. + +**Example scenario:** +``` +AWT-123 is being processed (workflow running) +Developer moves AWT-123 from "AI" to "In Progress" +``` + +**Expected behavior:** +1. Reconciliation detects AWT-123 is no longer in AI column +2. Verifies with Jira API that ticket truly left AI (not just poll lag) +3. Workflow cancelled +4. Sandbox stopped +5. Redis entry removed + +**Verifications:** +- Workflow status = cancelled +- No sandbox running for the ticket +- Redis has no entry for ticket + diff --git a/e2e/env.ts b/e2e/env.ts index c215c3e..0ab6c61 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -21,6 +21,9 @@ const schema = z.object({ AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), + /** Must match the deployed app's VERCEL_ENV (e.g. "preview", "production") */ + VERCEL_ENV: z.string().min(1).default("preview"), + VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), }); diff --git a/e2e/helpers/github.ts b/e2e/helpers/github.ts index ee9aa98..1119c95 100644 --- a/e2e/helpers/github.ts +++ b/e2e/helpers/github.ts @@ -58,3 +58,108 @@ export async function deleteBranch(branchName: string): Promise { }) .catch(() => {}); } + +export async function createBranch( + branchName: string, + baseBranch = "main", +): Promise { + const { data: ref } = await octokit.git.getRef({ + ...ownerRepo, + ref: `heads/${baseBranch}`, + }); + await octokit.git.createRef({ + ...ownerRepo, + ref: `refs/heads/${branchName}`, + sha: ref.object.sha, + }); + return ref.object.sha; +} + +export async function createOrUpdateFile( + branch: string, + filePath: string, + content: string, + message: string, +): Promise { + let fileSha: string | undefined; + try { + const { data } = await octokit.repos.getContent({ + ...ownerRepo, + path: filePath, + ref: branch, + }); + if (!Array.isArray(data) && data.type === "file") { + fileSha = data.sha; + } + } catch { + // File doesn't exist yet + } + + const { data } = await octokit.repos.createOrUpdateFileContents({ + ...ownerRepo, + path: filePath, + message, + content: Buffer.from(content).toString("base64"), + branch, + ...(fileSha ? { sha: fileSha } : {}), + }); + return data.commit.sha!; +} + +export async function openPR( + branch: string, + title: string, + body = "", +): Promise<{ number: number; url: string }> { + const { data } = await octokit.pulls.create({ + ...ownerRepo, + head: branch, + base: "main", + title, + body, + }); + return { number: data.number, url: data.html_url }; +} + +export async function getPRFiles( + prNumber: number, +): Promise> { + const { data } = await octokit.pulls.listFiles({ + ...ownerRepo, + pull_number: prNumber, + }); + return data.map((f) => ({ filename: f.filename, status: f.status! })); +} + +export async function isPRMergeable(prNumber: number): Promise { + const { data } = await octokit.pulls.get({ + ...ownerRepo, + pull_number: prNumber, + }); + return data.mergeable; +} + +export async function deleteFile( + branch: string, + filePath: string, + message: string, +): Promise { + try { + const { data } = await octokit.repos.getContent({ + ...ownerRepo, + path: filePath, + ref: branch, + }); + if (!Array.isArray(data) && data.type === "file") { + await octokit.repos.deleteFile({ + ...ownerRepo, + path: filePath, + message, + sha: data.sha, + branch, + }); + } + } catch { + // File doesn't exist, nothing to delete + } +} diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index b1b0850..2685999 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -112,6 +112,66 @@ export async function deleteTicket(ticketKey: string): Promise { }).catch(() => {}); } +export async function addAttachment( + ticketKey: string, + filename: string, + content: Buffer, +): Promise { + const form = new FormData(); + form.append("file", new Blob([new Uint8Array(content)]), filename); + + const res = await fetch( + `${e2eEnv.JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/attachments`, + { + method: "POST", + headers: { + Authorization: authHeader, + "X-Atlassian-Token": "no-check", + }, + body: form, + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Jira attachment upload failed: ${res.status} — ${text}`); + } +} + +export async function getTicketAttachments( + ticketKey: string, +): Promise< + Array<{ + id: string; + filename: string; + size: number; + mimeType: string; + contentUrl: string; + }> +> { + const data = await jiraRequest( + `/rest/api/3/issue/${ticketKey}?fields=attachment`, + ); + return (data.fields.attachment ?? []).map((a: any) => ({ + id: a.id, + filename: a.filename, + size: a.size, + mimeType: a.mimeType, + contentUrl: a.content, + })); +} + +export async function downloadJiraAttachment( + contentUrl: string, +): Promise { + const res = await fetch(contentUrl, { + headers: { Authorization: authHeader }, + }); + if (!res.ok) { + throw new Error(`Attachment download failed: ${res.status}`); + } + return Buffer.from(await res.arrayBuffer()); +} + function extractAdfText(adf: any): string { if (!adf) return ""; if (typeof adf === "string") return adf; diff --git a/e2e/helpers/redis.ts b/e2e/helpers/redis.ts index 967306b..ffc065e 100644 --- a/e2e/helpers/redis.ts +++ b/e2e/helpers/redis.ts @@ -1,7 +1,7 @@ import { Redis } from "@upstash/redis"; import { e2eEnv } from "../env.js"; -const HASH_KEY = "blazebot:active-runs"; +const HASH_KEY = `blazebot:active-runs:${e2eEnv.VERCEL_ENV}`; const redis = new Redis({ url: e2eEnv.AI_WORKFLOW_KV_REST_API_URL, @@ -12,6 +12,7 @@ export async function getRunId(ticketKey: string): Promise { return redis.hget(HASH_KEY, ticketKey); } + export async function listAll(): Promise< Array<{ ticketKey: string; runId: string }> > { diff --git a/e2e/helpers/sandbox.ts b/e2e/helpers/sandbox.ts new file mode 100644 index 0000000..694a169 --- /dev/null +++ b/e2e/helpers/sandbox.ts @@ -0,0 +1,42 @@ +/** + * Best-effort cleanup: find and stop any running sandboxes whose checked-out + * branch matches `blazebot/{ticketKey}`. + */ +export async function stopSandboxesForTicket( + ticketKey: string, +): Promise { + const expectedBranch = `blazebot/${ticketKey.trim().toLowerCase()}`; + try { + const { Sandbox } = await import("@vercel/sandbox"); + const { json } = await Sandbox.list({ limit: 100 }); + const running = json.sandboxes.filter( + (s: { status?: string }) => s.status === "running", + ); + + let stopped = 0; + for (const entry of running) { + try { + const sandbox = await Sandbox.get({ sandboxId: entry.id }); + if (sandbox.status !== "running") continue; + + const result = await sandbox.runCommand({ + cmd: "git", + args: ["rev-parse", "--abbrev-ref", "HEAD"], + cwd: "/vercel/sandbox", + }); + const branch = result.exitCode === 0 + ? (await result.stdout()).trim() + : null; + if (branch !== expectedBranch) continue; + + await sandbox.stop(); + stopped++; + } catch { + // Best-effort — skip individual sandbox errors + } + } + return stopped; + } catch { + return 0; + } +} diff --git a/e2e/tier2/implementation-and-review.test.ts b/e2e/tier2/implementation-and-review.test.ts deleted file mode 100644 index 13891f2..0000000 --- a/e2e/tier2/implementation-and-review.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { - findPR, - getPRCommits, - addPRComment, - closePR, - deleteBranch, -} from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -describe("implementation happy path → review-fix flow", () => { - let ticketKey: string; - let branchName: string; - let prNumber: number | undefined; - - afterAll(async () => { - if (prNumber) await closePR(prNumber); - if (branchName) await deleteBranch(branchName); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("implements a ticket and creates a PR", async () => { - // Create a ticket with a simple, concrete task - const ticket = await createTestTicket({ - summary: `[E2E] Add GET /ping endpoint`, - description: - "Add a GET /api/ping API route that returns { ping: 'pong' } with status 200. Create only one route file at app/api/ping/route.ts.", - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // Move to AI column and dispatch via cron poll - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - const { body } = await waitFor( - async () => { - const res = await callCronPoll(); - if (res.status === 200 && res.body.started?.includes(ticketKey)) return res; - return null; - }, - { description: `cron dispatches ${ticketKey}`, timeoutMs: 30_000, intervalMs: 3_000 }, - ); - expect(body.status).toBe("ok"); - - // Wait for PR to appear (up to 35 min) - const pr = await waitFor(() => findPR(branchName), { - description: `PR for branch ${branchName}`, - timeoutMs: 2_100_000, - }); - prNumber = pr.number; - - // Verify PR has commits - const commits = await getPRCommits(prNumber); - expect(commits.length).toBeGreaterThan(0); - - // Verify ticket moved to AI Review - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { - description: `ticket ${ticketKey} moved to ${e2eEnv.COLUMN_AI_REVIEW}`, - timeoutMs: 60_000, - }, - ); - - // Verify Redis entry is cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { - description: `Redis entry cleaned for ${ticketKey}`, - timeoutMs: 30_000, - }, - ); - }); - - it("fixes PR based on review feedback", async () => { - // This test depends on the previous test having created a PR - expect(prNumber).toBeDefined(); - - // Record commit count before review-fix - const commitsBefore = await getPRCommits(prNumber!); - const commitCountBefore = commitsBefore.length; - - // Add a review comment - await addPRComment( - prNumber!, - "Rename the `/ping` endpoint to `/healthcheck` — remove the old `/ping` route and update its handler, tests, and any references so only `/healthcheck` exists.", - ); - - // Move ticket back to AI column and dispatch review-fix via cron poll - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - await waitFor( - async () => { - const res = await callCronPoll(); - if (res.status === 200 && res.body.started?.includes(ticketKey)) return res; - return null; - }, - { description: `cron dispatches review-fix for ${ticketKey}`, timeoutMs: 30_000, intervalMs: 3_000 }, - ); - - // Wait for ticket to move back to AI Review (review-fix completed) - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { - description: `ticket ${ticketKey} moved back to ${e2eEnv.COLUMN_AI_REVIEW} after review-fix`, - timeoutMs: 2_100_000, - }, - ); - - // Verify PR has new commits - const commitsAfter = await getPRCommits(prNumber!); - expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); - - // Verify Redis entry is cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { - description: `Redis entry cleaned for ${ticketKey} after review-fix`, - timeoutMs: 30_000, - }, - ); - }); -}); diff --git a/e2e/tier2/us1-clear-ticket-pr.test.ts b/e2e/tier2/us1-clear-ticket-pr.test.ts new file mode 100644 index 0000000..bbd5152 --- /dev/null +++ b/e2e/tier2/us1-clear-ticket-pr.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { + findPR, + getPRCommits, + closePR, + deleteBranch, +} from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-1: Clear ticket produces a PR [GitHub] + * + * When a ticket with clear requirements is moved to the AI column, + * the agent implements the feature and creates a PR for review. + */ +describe("US-1: Clear ticket produces a PR", () => { + let ticketKey: string; + let branchName: string; + let prNumber: number | undefined; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (prNumber) await closePR(prNumber); + if (branchName) await deleteBranch(branchName); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("implements a clear ticket and creates a PR on the correct branch", async () => { + // 1. Create ticket with clear, concrete requirements + const ticket = await createTestTicket({ + summary: "[E2E] Add GET /api/health endpoint", + description: [ + "Create a GET /api/health route that returns { status: \"ok\" } with HTTP 200.", + "", + "Acceptance criteria:", + "- Returns JSON { status: \"ok\" }", + "- HTTP 200 response", + "- Create only one route file", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Move to AI column — webhook or cron triggers dispatch + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // Poke cron to ensure dispatch if webhook didn't fire + await callCronPoll(); + + // 3. Wait for PR to appear on the expected branch (the definitive signal) + const pr = await waitFor(() => findPR(branchName), { + description: `PR on branch ${branchName}`, + timeoutMs: 2_000_000, + }); + prNumber = pr.number; + + // 4. Verify: PR has at least 1 commit + const commits = await getPRCommits(prNumber); + expect(commits.length).toBeGreaterThan(0); + + // 5. Verify: ticket moved to AI Review + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; + }, + { description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_AI_REVIEW}`, timeoutMs: 60_000 }, + ); + + // 6. Verify: Redis entry cleaned up (no active run) + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + ); + }); +}); diff --git a/e2e/tier2/us2-attachments.test.ts b/e2e/tier2/us2-attachments.test.ts new file mode 100644 index 0000000..3527024 --- /dev/null +++ b/e2e/tier2/us2-attachments.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + addAttachment, + getTicketAttachments, + downloadJiraAttachment, + deleteTicket, +} from "../helpers/jira.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-2: Ticket with attachments (integration test: fetch + write phase) + * + * Tests that attachments on a Jira ticket can be downloaded and written + * to a sandbox at the expected paths. NOT a full E2E workflow — tests + * only the attachment fetch and write phases. + */ +describe("US-2: Ticket with attachments (fetch + write phase)", () => { + let ticketKey: string; + let sandbox: { stop: () => Promise } | undefined; + + afterAll(async () => { + if (sandbox) await sandbox.stop().catch(() => {}); + if (ticketKey) await deleteTicket(ticketKey); + }); + + it("downloads attachments from Jira and writes them to a sandbox", async () => { + // 1. Create a ticket + const ticket = await createTestTicket({ + summary: "[E2E] Create user profile card component", + description: + "Build a profile card component matching the attached mockup.", + }); + ticketKey = ticket.ticketKey; + + // 2. Upload test attachments to Jira + const mockupContent = Buffer.alloc(1024, 0x89); // 1 KB placeholder + const tokensContent = Buffer.from( + JSON.stringify({ primary: "#FF6B35", spacing: "16px" }), + ); + + await addAttachment(ticketKey, "profile-mockup.png", mockupContent); + await addAttachment(ticketKey, "design-tokens.json", tokensContent); + + // 3. Fetch attachment metadata — verify count + const attachments = await getTicketAttachments(ticketKey); + expect(attachments).toHaveLength(2); + + // 4. Download each attachment — verify content is non-empty + const downloaded = await Promise.all( + attachments.map(async (att) => { + const content = await downloadJiraAttachment(att.contentUrl); + return { filename: att.filename, content, size: content.length }; + }), + ); + for (const d of downloaded) { + expect(d.size).toBeGreaterThan(0); + } + expect(downloaded.find((d) => d.filename === "profile-mockup.png")).toBeDefined(); + expect(downloaded.find((d) => d.filename === "design-tokens.json")).toBeDefined(); + + // 5. Create a sandbox and write files to /tmp/attachments/ + const { Sandbox } = await import("@vercel/sandbox"); + const sbx = await Sandbox.create({ + source: { + type: "git", + url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`, + username: "x-access-token", + password: e2eEnv.E2E_GITHUB_TOKEN, + revision: "main", + depth: 1, + }, + runtime: "node24", + timeout: 120_000, + }); + sandbox = sbx; + + await sbx.runCommand("mkdir", ["-p", "/tmp/attachments"]); + await sbx.writeFiles( + downloaded.map((d) => ({ + path: `/tmp/attachments/${d.filename}`, + content: d.content, + })), + ); + + // 6. Verify: files exist at expected paths inside the sandbox + for (const d of downloaded) { + const result = await sbx.runCommand("test", [ + "-f", + `/tmp/attachments/${d.filename}`, + ]); + expect(result.exitCode).toBe(0); + } + + // 7. Verify: file contents are valid (not corrupted) + const pngStat = await sbx.runCommand("wc", [ + "-c", + "/tmp/attachments/profile-mockup.png", + ]); + const pngSize = parseInt( + (await pngStat.stdout()).trim().split(/\s+/)[0], + 10, + ); + expect(pngSize).toBe(mockupContent.length); + + const jsonResult = await sbx.runCommand("cat", [ + "/tmp/attachments/design-tokens.json", + ]); + const jsonContent = (await jsonResult.stdout()).trim(); + expect(() => JSON.parse(jsonContent)).not.toThrow(); + expect(JSON.parse(jsonContent).primary).toBe("#FF6B35"); + }); +}); diff --git a/e2e/tier2/us3-review-fix-cycle.test.ts b/e2e/tier2/us3-review-fix-cycle.test.ts new file mode 100644 index 0000000..a65630e --- /dev/null +++ b/e2e/tier2/us3-review-fix-cycle.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { + createBranch, + createOrUpdateFile, + openPR, + findPR, + getPRCommits, + getPRFiles, + addPRComment, + closePR, + deleteBranch, +} from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-3: Review feedback triggers a fix cycle [GitHub] + * + * When a developer leaves review comments on the agent's PR and moves + * the ticket back to AI, the agent addresses the feedback and pushes + * updates to the same PR — no duplicate PR created. + * + * Setup uses GitHub API to create branch + code + PR in seconds, + * instead of waiting for a full workflow run. + */ +describe("US-3: Review feedback triggers a fix cycle", () => { + let ticketKey: string; + let branchName: string; + let prNumber: number | undefined; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (prNumber) await closePR(prNumber); + if (branchName) await deleteBranch(branchName); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("addresses review comments and pushes updates to the same PR", async () => { + // --- Setup: create ticket + branch + initial code + PR via GitHub API --- + + const ticket = await createTestTicket({ + summary: "[E2E] Add GET /api/ping endpoint", + description: [ + "Add a GET /api/ping API route that returns { ping: 'pong' } with status 200.", + "Create only one route file at app/api/ping/route.ts.", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // Create branch with a simple implementation + await createBranch(branchName); + await createOrUpdateFile( + branchName, + "app/api/ping/route.ts", + [ + 'import { NextResponse } from "next/server";', + "", + "export async function GET() {", + ' return NextResponse.json({ ping: "pong" });', + "}", + "", + ].join("\n"), + "feat: add GET /api/ping endpoint", + ); + + // Create PR and record initial commit count + const pr = await openPR( + branchName, + `[${ticketKey}] Add GET /api/ping endpoint`, + ); + prNumber = pr.number; + + const commitsBefore = await getPRCommits(prNumber); + const commitCountBefore = commitsBefore.length; + + // Add a review comment requesting a rename + await addPRComment( + prNumber, + 'Rename the `/ping` endpoint to `/healthcheck` — remove the old `/ping` route entirely and create `/healthcheck` instead.', + ); + + // --- Act: move ticket to AI to trigger the review-fix workflow --- + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // Poke cron to ensure dispatch if webhook didn't fire + await callCronPoll(); + + // --- Assert --- + + // Ticket moves to AI Review (workflow completed) + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; + }, + { + description: `ticket → ${e2eEnv.COLUMN_AI_REVIEW} after review-fix`, + timeoutMs: 2_000_000, + }, + ); + + // PR has more commits than before the review fix + const commitsAfter = await getPRCommits(prNumber); + expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); + + // No duplicate PR — same PR number is still the only open PR for this branch + const currentPR = await findPR(branchName); + expect(currentPR).not.toBeNull(); + expect(currentPR!.number).toBe(prNumber); + + // Old /ping route removed, /healthcheck exists (check PR aggregate diff) + const prFiles = await getPRFiles(prNumber); + const filenames = prFiles.map((f) => f.filename); + expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); + expect(filenames.some((f) => f.includes("/ping/"))).toBe(false); + + // Redis cleaned up + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + ); + }); +}); diff --git a/e2e/tier2/us4-merge-conflict-rebase.test.ts b/e2e/tier2/us4-merge-conflict-rebase.test.ts new file mode 100644 index 0000000..f265900 --- /dev/null +++ b/e2e/tier2/us4-merge-conflict-rebase.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { + createBranch, + createOrUpdateFile, + openPR, + getPRCommits, + isPRMergeable, + closePR, + deleteBranch, + deleteFile, +} from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-4: PR with merge conflicts — agent rebases [GitHub] + * + * When a ticket's PR has merge conflicts with main, moving the ticket + * back to AI triggers the agent to resolve the conflicts. The sandbox + * is provisioned with `mergeBase` so the agent can see and fix them. + * + * Setup uses GitHub API to create a branch, add a file, add a + * CONFLICTING file on main, then create a PR that shows conflicts. + */ +describe("US-4: PR with merge conflicts — agent rebases", () => { + const uniqueDir = `blazebot-e2e-${Date.now()}`; + const conflictFile = `${uniqueDir}/data.txt`; + let ticketKey: string; + let branchName: string; + let prNumber: number | undefined; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (prNumber) await closePR(prNumber); + if (branchName) await deleteBranch(branchName); + await deleteFile( + "main", + conflictFile, + "[E2E] cleanup conflict test file", + ).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("resolves merge conflicts and pushes an updated branch", async () => { + // --- Setup: create a PR that has merge conflicts --- + + const ticket = await createTestTicket({ + summary: `[E2E] Add greeting file at ${conflictFile}`, + description: [ + `Create a file at ${conflictFile} containing exactly: "Hello from blazebot"`, + "", + "Acceptance criteria:", + `- File exists at ${conflictFile}`, + '- File content is exactly "Hello from blazebot"', + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // Create branch and add the file with the correct content + await createBranch(branchName); + await createOrUpdateFile( + branchName, + conflictFile, + "Hello from blazebot\n", + "feat: add greeting file", + ); + + // Create a CONFLICTING version of the same file on main + await createOrUpdateFile( + "main", + conflictFile, + "This space is reserved\n", + "[E2E] create conflict baseline on main", + ); + + // Create PR — will have merge conflicts since both sides added the same file + const pr = await openPR( + branchName, + `[${ticketKey}] Add greeting file`, + ); + prNumber = pr.number; + + // Wait for GitHub to detect the merge conflict + await waitFor( + async () => { + const mergeable = await isPRMergeable(prNumber!); + return mergeable === false ? true : null; + }, + { + description: `PR #${prNumber} detected as conflicting`, + timeoutMs: 30_000, + intervalMs: 3_000, + }, + ); + + const commitsBefore = await getPRCommits(prNumber); + const commitCountBefore = commitsBefore.length; + + // --- Act: move ticket to AI to trigger conflict resolution --- + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // Poke cron to ensure dispatch if webhook didn't fire + await callCronPoll(); + + // --- Assert --- + + // Ticket moves to AI Review (workflow completed successfully) + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; + }, + { + description: `ticket → ${e2eEnv.COLUMN_AI_REVIEW} after conflict resolution`, + timeoutMs: 2_000_000, + }, + ); + + // PR no longer has merge conflicts + await waitFor( + async () => { + const mergeable = await isPRMergeable(prNumber!); + return mergeable === true ? true : null; + }, + { + description: `PR #${prNumber} is now mergeable`, + timeoutMs: 30_000, + intervalMs: 3_000, + }, + ); + + // PR has new commits (conflict resolution commit) + const commitsAfter = await getPRCommits(prNumber); + expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); + + // Ticket status is AI Review + const finalStatus = await getTicketStatus(ticketKey); + expect(finalStatus).toBe(e2eEnv.COLUMN_AI_REVIEW); + + // Redis cleaned up + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + ); + }); +}); From 3095eb160010719d73a28b1851e0170777ffa16b Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 14:49:47 +0200 Subject: [PATCH 34/71] feat: go back to 3 phase flow --- src/lib/prompts.ts | 27 ++-- src/sandbox/agent-runner.test.ts | 10 +- src/sandbox/agent-runner.ts | 4 +- src/sandbox/context.test.ts | 103 -------------- src/sandbox/context.ts | 51 ------- src/workflows/agent.ts | 222 +++++++++++++------------------ 6 files changed, 118 insertions(+), 299 deletions(-) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 6a7b9f3..a706613 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -111,8 +111,7 @@ You have access to **superpowers skills** installed globally. Use them. 1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists. If it exists, read it. 2. Read the plan from the "Research & Plan" section above. -3. If review feedback is included (retry scenario): focus on fixing the flagged issues. Do not redo work that was approved. -4. Execute each step in the plan, in order. +3. Execute each step in the plan, in order. 5. If the repo has tests: run them to ensure nothing is broken. 6. **Update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. 7. Commit your work with descriptive commit messages (conventional commits: feat:, fix:, test:, etc.). @@ -165,13 +164,15 @@ Return a JSON object with: const reviewPrompt = `# Instructions -You are an AI code review agent. Your job is to review the implementation diff against the plan and acceptance criteria. +You are an AI code review agent. Your job is to review the implementation diff against the plan and acceptance criteria, and **fix any issues you find**. ## Superpowers You have access to **superpowers skills** installed globally. Use them. -- **Use \`requesting-code-review\` to dispatch a code-reviewer subagent** — this is your primary tool. +- **Use \`requesting-code-review\` to dispatch a code-reviewer subagent** — this is your primary tool for identifying issues. +- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. +- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. ## Process @@ -181,7 +182,10 @@ You have access to **superpowers skills** installed globally. Use them. 4. Check code quality, test coverage, edge cases. 5. Invoke \`requesting-code-review\` skill to dispatch a code-reviewer subagent. 6. Combine your findings with the subagent's findings. -7. Output your verdict. +7. **Fix any issues found** — apply code changes directly. This is the final phase, there is no re-implementation loop. +8. If you made changes, run tests and quality checks to verify the fixes. +9. Commit any fixes with descriptive commit messages (conventional commits: fix:, refactor:, test:, etc.). +10. Output your verdict. ## Review Criteria @@ -193,16 +197,17 @@ You have access to **superpowers skills** installed globally. Use them. ## Constraints -- **NO coding** — do not write or modify any code -- **NO commits** — do not create any git commits -- Only review and report +- Fix issues directly — do not just report them and request changes. +- Do not refactor code outside the scope of the plan. +- Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). +- Do NOT add \`blazebot/memory\` to \`.gitignore\` unless the user explicitly asks you to. ## Output Return a JSON object with: -- \`result\`: "approved" if the implementation is ready, "changes_requested" if issues need fixing, "failed" if review itself failed. -- \`feedback\`: Detailed review notes. -- \`issues\`: Array of specific issues — each with \`file\`, \`description\`, \`severity\` ("critical" or "suggestion"). Only include issues that MUST be fixed for \`changes_requested\`. +- \`result\`: "approved" if the implementation is ready (including after your fixes), "failed" if review itself failed or issues are unfixable. +- \`feedback\`: Detailed review notes, including what you fixed. +- \`issues\`: Array of issues found — each with \`file\`, \`description\`, \`severity\` ("critical" or "suggestion"). Include both fixed and unfixable issues. - \`error\`: Failure details (when failed).`; const prompts: Record = { diff --git a/src/sandbox/agent-runner.test.ts b/src/sandbox/agent-runner.test.ts index a3d8eea..d0581a6 100644 --- a/src/sandbox/agent-runner.test.ts +++ b/src/sandbox/agent-runner.test.ts @@ -194,16 +194,16 @@ describe("parseReviewOutput", () => { expect(output.feedback).toBe("Looks good"); }); - it("parses changes_requested result with issues", () => { + it("parses approved result with issues", () => { const raw = JSON.stringify({ - result: "changes_requested", - feedback: "Several issues found", + result: "approved", + feedback: "Fixed several issues", issues: [ - { file: "src/foo.ts", description: "Missing null check", severity: "critical" }, + { file: "src/foo.ts", description: "Fixed missing null check", severity: "critical" }, ], }); const output = parseReviewOutput(raw); - expect(output.result).toBe("changes_requested"); + expect(output.result).toBe("approved"); expect(output.issues).toHaveLength(1); expect(output.issues[0].severity).toBe("critical"); }); diff --git a/src/sandbox/agent-runner.ts b/src/sandbox/agent-runner.ts index e53568e..b8a0b39 100644 --- a/src/sandbox/agent-runner.ts +++ b/src/sandbox/agent-runner.ts @@ -135,7 +135,7 @@ export function parseResearchStatus(raw: string): ResearchResult { // --- Review Output Schema --- const reviewOutputSchema = z.object({ - result: z.enum(["approved", "changes_requested", "failed"]), + result: z.enum(["approved", "failed"]), feedback: z.string(), issues: z.array(z.object({ file: z.string(), @@ -152,7 +152,7 @@ export const REVIEW_SCHEMA = JSON.stringify({ properties: { result: { type: "string", - enum: ["approved", "changes_requested", "failed"], + enum: ["approved", "failed"], }, feedback: { type: "string" }, issues: { diff --git a/src/sandbox/context.test.ts b/src/sandbox/context.test.ts index 96bca01..9360bbc 100644 --- a/src/sandbox/context.test.ts +++ b/src/sandbox/context.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest"; import { assembleResearchPlanContext, assembleImplementationContext, - assembleImplementationRetryContext, assembleReviewContext, formatCheckResults, } from "./context.js"; @@ -215,108 +214,6 @@ describe("assembleImplementationContext (new)", () => { }); }); -describe("assembleImplementationRetryContext", () => { - it("includes plan and review feedback", () => { - const result = assembleImplementationRetryContext({ - ticket: { - identifier: "TEST-1", - title: "Add login page", - description: "Build a login page", - acceptanceCriteria: "User can log in", - comments: [], - }, - prompt: "prompt", - researchPlanMarkdown: "# Plan\n1. Create LoginForm", - reviewFeedback: { - result: "changes_requested", - feedback: "Missing error handling", - issues: [ - { file: "src/LoginForm.tsx", description: "No null check", severity: "critical" }, - ], - }, - }); - - expect(result).toContain("## Research & Plan"); - expect(result).toContain("Create LoginForm"); - expect(result).toContain("## Review Feedback"); - expect(result).toContain("Missing error handling"); - expect(result).toContain("src/LoginForm.tsx"); - expect(result).toContain("No null check"); - expect(result).toContain("critical"); - }); - - it("renders attachments index when attachments are provided", () => { - const result = assembleImplementationRetryContext({ - ticket: { - identifier: "TEST-3", - title: "With files", - description: "desc", - acceptanceCriteria: "ac", - comments: [], - }, - prompt: "prompt", - researchPlanMarkdown: "plan", - reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, - attachments: [ - { - filename: "mockup.png", - originalFilename: "mockup.png", - mimeType: "image/png", - size: 348_192, - content: Buffer.from([]), - }, - ], - }); - expect(result).toContain("## Attachments"); - expect(result).toContain("/tmp/attachments/mockup.png"); - - const atIdx = result.indexOf("## Attachments"); - const acIdx = result.indexOf("## Acceptance Criteria"); - expect(atIdx).toBeGreaterThan(-1); - expect(acIdx).toBeGreaterThan(atIdx); - }); - - it("omits attachments section when list is empty or absent", () => { - const withoutField = assembleImplementationRetryContext({ - ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, - prompt: "p", - researchPlanMarkdown: "plan", - reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, - }); - expect(withoutField).not.toContain("## Attachments"); - - const withEmpty = assembleImplementationRetryContext({ - ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, - prompt: "p", - researchPlanMarkdown: "plan", - reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, - attachments: [], - }); - expect(withEmpty).not.toContain("## Attachments"); - }); - - it("shows failed attachments in the index even when no bytes downloaded", () => { - const result = assembleImplementationRetryContext({ - ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, - prompt: "p", - researchPlanMarkdown: "plan", - reviewFeedback: { result: "changes_requested", feedback: "fb", issues: [] }, - attachments: [ - { - filename: "spec.pdf", - originalFilename: "spec.pdf", - mimeType: "application/pdf", - size: 0, - failed: { reason: "HTTP 500", attempts: 3 }, - }, - ], - }); - expect(result).toContain("## Attachments"); - expect(result).toContain("⚠️"); - expect(result).toContain("spec.pdf"); - }); -}); - describe("assembleReviewContext", () => { it("includes plan and git diff", () => { const result = assembleReviewContext({ diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 6f4783f..558e897 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,5 +1,4 @@ import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; -import type { ReviewOutput } from "./agent-runner.js"; import type { DownloadedAttachment } from "./attachments.js"; import { formatAttachmentsIndex } from "./attachments.js"; @@ -28,14 +27,6 @@ export interface ImplementationContextInput { attachments?: DownloadedAttachment[]; } -export interface ImplementationRetryContextInput { - ticket: TicketData; - prompt: string; - researchPlanMarkdown: string; - reviewFeedback: ReviewOutput; - attachments?: DownloadedAttachment[]; -} - export interface ReviewContextInput { ticket: TicketData; prompt: string; @@ -118,41 +109,6 @@ ${prompt} `; } -export function assembleImplementationRetryContext(input: ImplementationRetryContextInput): string { - const { ticket, prompt, researchPlanMarkdown, reviewFeedback, attachments } = input; - const attachmentsSection = renderAttachmentsSection(attachments); - return `# Requirements - -## Ticket ID - -${ticket.identifier} - -## Ticket - -${ticket.title} -${attachmentsSection} -## Acceptance Criteria - -${ticket.acceptanceCriteria || "None specified."} - -## Research & Plan - -${researchPlanMarkdown} - -## Review Feedback - -${reviewFeedback.feedback} - -### Issues - -${formatReviewIssues(reviewFeedback.issues)} - ---- - -${prompt} -`; -} - export function assembleReviewContext(input: ReviewContextInput): string { const { ticket, prompt, researchPlanMarkdown, gitDiff, attachments } = input; const attachmentsSection = renderAttachmentsSection(attachments); @@ -186,13 +142,6 @@ ${prompt} `; } -function formatReviewIssues(issues: Array<{ file: string; description: string; severity: string }>): string { - if (issues.length === 0) return "No specific issues listed."; - return issues - .map((i) => `- **[${i.severity}]** ${i.file}: ${i.description}`) - .join("\n"); -} - function formatComments( comments: Array<{ author: string; body: string; createdAt?: string }>, ): string { diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index a136c0c..42675af 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -307,8 +307,6 @@ async function pollUntilDone( // --- Main Workflow --- -const MAX_REVIEW_RETRIES = 2; - export async function agentWorkflow(ticketId: string) { "use workflow"; @@ -317,7 +315,7 @@ export async function agentWorkflow(ticketId: string) { const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); const { parseResearchStatus, parseAgentOutput, parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = await import("../sandbox/agent-runner.js"); - const { assembleResearchPlanContext, assembleImplementationContext, assembleImplementationRetryContext, assembleReviewContext } = + const { assembleResearchPlanContext, assembleImplementationContext, assembleReviewContext } = await import("../sandbox/context.js"); const { collectPhaseOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); @@ -424,134 +422,104 @@ export async function agentWorkflow(ticketId: string) { const researchPlanMarkdown = research.body; - // ========== PHASE 2 & 3 LOOP ========== + // ========== PHASE 2: Implementation ========== const phaseUsages: Record = { Research: researchUsage }; - let reviewRetries = 0; - let lastReviewFeedback: ReviewOutput | undefined; - - while (true) { - // ========== PHASE 2: Implementation ========== - await configureStopHook(sandboxId, true); - - const implInput = lastReviewFeedback - ? assembleImplementationRetryContext({ - ticket: ticketData, - prompt: getPrompt("implement.md"), - researchPlanMarkdown, - reviewFeedback: lastReviewFeedback, - attachments: downloadedAttachments, - }) - : assembleImplementationContext({ - ticket: ticketData, - prompt: getPrompt("implement.md"), - researchPlanMarkdown, - attachments: downloadedAttachments, - }); - - const implScript = buildPhaseScript({ - model: env.CLAUDE_MODEL, - phase: "impl", - inputFile: "/tmp/impl-requirements.md", - outputFile: "/tmp/impl-stdout.txt", - stderrFile: "/tmp/impl-stderr.txt", - sentinelFile: "/tmp/impl-done", - jsonSchema: AGENT_SCHEMA, - }); - - await writeAndStartPhase( - sandboxId, - "/tmp/impl-requirements.md", implInput, - "/tmp/impl-wrapper.sh", implScript, - ); - const implDone = await pollUntilDone(sandboxId, "/tmp/impl-done", 35); - let implOutput: AgentOutput; - - if (implDone) { - const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); - const implLabel = reviewRetries > 0 ? `Impl retry ${reviewRetries}` : "Impl"; - phaseUsages[implLabel] = extractUsage(implRaw); - implOutput = parseAgentOutput(implRaw); - } else { - implOutput = { result: "failed", error: "Implementation phase timed out" }; - } - - if (implOutput.result === "clarification_needed") { - await postClarificationAndMoveBack( - ticketId, - implOutput.questions ?? [], - env.COLUMN_BACKLOG, - ); - await notifySlack(`Task ${ticket.identifier} needs clarification`); - await unregisterRun(ticket.identifier); - return; - } - - if (implOutput.result === "failed") { - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}`); - await unregisterRun(ticket.identifier); - return; - } - - // ========== PHASE 3: Review ========== - await configureStopHook(sandboxId, false); - - const gitDiff = await captureGitDiff(sandboxId); - - const reviewInput = assembleReviewContext({ - ticket: ticketData, - prompt: getPrompt("review.md"), - researchPlanMarkdown, - gitDiff, - attachments: downloadedAttachments, - }); - - const reviewScript = buildPhaseScript({ - model: env.CLAUDE_MODEL, - phase: "review", - inputFile: "/tmp/review-requirements.md", - outputFile: "/tmp/review-stdout.txt", - stderrFile: "/tmp/review-stderr.txt", - sentinelFile: "/tmp/review-done", - jsonSchema: REVIEW_SCHEMA, - }); - - await writeAndStartPhase( - sandboxId, - "/tmp/review-requirements.md", reviewInput, - "/tmp/review-wrapper.sh", reviewScript, + await configureStopHook(sandboxId, true); + + const implInput = assembleImplementationContext({ + ticket: ticketData, + prompt: getPrompt("implement.md"), + researchPlanMarkdown, + attachments: downloadedAttachments, + }); + + const implScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "impl", + inputFile: "/tmp/impl-requirements.md", + outputFile: "/tmp/impl-stdout.txt", + stderrFile: "/tmp/impl-stderr.txt", + sentinelFile: "/tmp/impl-done", + jsonSchema: AGENT_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/impl-requirements.md", implInput, + "/tmp/impl-wrapper.sh", implScript, + ); + + const implDone = await pollUntilDone(sandboxId, "/tmp/impl-done", 35); + let implOutput: AgentOutput; + + if (implDone) { + const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); + phaseUsages["Impl"] = extractUsage(implRaw); + implOutput = parseAgentOutput(implRaw); + } else { + implOutput = { result: "failed", error: "Implementation phase timed out" }; + } + + if (implOutput.result === "clarification_needed") { + await postClarificationAndMoveBack( + ticketId, + implOutput.questions ?? [], + env.COLUMN_BACKLOG, ); + await notifySlack(`Task ${ticket.identifier} needs clarification`); + await unregisterRun(ticket.identifier); + return; + } + + if (implOutput.result === "failed") { + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}`); + await unregisterRun(ticket.identifier); + return; + } + + // ========== PHASE 3: Review ========== + await configureStopHook(sandboxId, true); + + const gitDiff = await captureGitDiff(sandboxId); + + const reviewInput = assembleReviewContext({ + ticket: ticketData, + prompt: getPrompt("review.md"), + researchPlanMarkdown, + gitDiff, + attachments: downloadedAttachments, + }); + + const reviewScript = buildPhaseScript({ + model: env.CLAUDE_MODEL, + phase: "review", + inputFile: "/tmp/review-requirements.md", + outputFile: "/tmp/review-stdout.txt", + stderrFile: "/tmp/review-stderr.txt", + sentinelFile: "/tmp/review-done", + jsonSchema: REVIEW_SCHEMA, + }); + + await writeAndStartPhase( + sandboxId, + "/tmp/review-requirements.md", reviewInput, + "/tmp/review-wrapper.sh", reviewScript, + ); + + const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); + let reviewOutput: ReviewOutput; + + if (reviewDone) { + const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); + phaseUsages["Review"] = extractUsage(reviewRaw); + reviewOutput = parseReviewOutput(reviewRaw); + } else { + reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; + } - const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); - let reviewOutput: ReviewOutput; - - if (reviewDone) { - const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); - const reviewLabel = reviewRetries > 0 ? `Review retry ${reviewRetries}` : "Review"; - phaseUsages[reviewLabel] = extractUsage(reviewRaw); - reviewOutput = parseReviewOutput(reviewRaw); - } else { - reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; - } - - if (reviewOutput.result === "approved") { - break; // Exit loop → push - } - - if (reviewOutput.result === "changes_requested") { - reviewRetries++; - if (reviewRetries > MAX_REVIEW_RETRIES) { - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: review rejected after ${MAX_REVIEW_RETRIES} retries`); - await unregisterRun(ticket.identifier); - return; - } - lastReviewFeedback = reviewOutput; - continue; // Loop back to Phase 2 - } - - // result === "failed" + if (reviewOutput.result === "failed") { await moveTicket(ticketId, env.COLUMN_BACKLOG); await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}`); await unregisterRun(ticket.identifier); From 896f3708b66f68398347ab13555956ac259e90a3 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 15:38:50 +0200 Subject: [PATCH 35/71] feat: new tests --- e2e/helpers/github.ts | 20 +++ e2e/scripts/create-attachment-ticket.ts | 26 ++++ e2e/scripts/delete-ticket.ts | 10 ++ e2e/tier1/cron-poll.test.ts | 46 ------ e2e/tier1/cron-reconciliation.test.ts | 37 ----- e2e/tier2/clarification-flow.test.ts | 78 ----------- e2e/tier2/stop-hook-commit.test.ts | 121 ---------------- e2e/tier2/us1-clear-ticket-pr.test.ts | 36 +++-- e2e/tier2/us2-attachments.test.ts | 146 +++++++++++++++----- e2e/tier2/us3-review-fix-cycle.test.ts | 34 ++++- e2e/tier2/us4-merge-conflict-rebase.test.ts | 16 ++- 11 files changed, 236 insertions(+), 334 deletions(-) create mode 100644 e2e/scripts/create-attachment-ticket.ts create mode 100644 e2e/scripts/delete-ticket.ts delete mode 100644 e2e/tier1/cron-poll.test.ts delete mode 100644 e2e/tier1/cron-reconciliation.test.ts delete mode 100644 e2e/tier2/clarification-flow.test.ts delete mode 100644 e2e/tier2/stop-hook-commit.test.ts diff --git a/e2e/helpers/github.ts b/e2e/helpers/github.ts index 1119c95..08e5258 100644 --- a/e2e/helpers/github.ts +++ b/e2e/helpers/github.ts @@ -139,6 +139,26 @@ export async function isPRMergeable(prNumber: number): Promise { return data.mergeable; } +/** Read a file's text content from a branch. Returns null if not found. */ +export async function getFileContent( + branch: string, + filePath: string, +): Promise { + try { + const { data } = await octokit.repos.getContent({ + ...ownerRepo, + path: filePath, + ref: branch, + }); + if (!Array.isArray(data) && data.type === "file") { + return Buffer.from(data.content, "base64").toString("utf-8"); + } + return null; + } catch { + return null; + } +} + export async function deleteFile( branch: string, filePath: string, diff --git a/e2e/scripts/create-attachment-ticket.ts b/e2e/scripts/create-attachment-ticket.ts new file mode 100644 index 0000000..abe2ace --- /dev/null +++ b/e2e/scripts/create-attachment-ticket.ts @@ -0,0 +1,26 @@ +import { createTestTicket, addAttachment, moveTicketToColumn } from "../helpers/jira.js"; +import { e2eEnv } from "../env.js"; + +async function main() { + const ticket = await createTestTicket({ + summary: "[E2E] Create user profile card component", + description: + "Build a profile card component matching the attached mockup and specs.", + }); + console.log("Created ticket:", ticket.ticketKey); + + // Ensure ticket stays in Backlog (not dispatched) + await moveTicketToColumn(ticket.ticketKey, e2eEnv.COLUMN_BACKLOG); + console.log("Moved to Backlog"); + + await addAttachment(ticket.ticketKey, "profile-mockup.png", Buffer.alloc(1024, 0x89)); + await addAttachment(ticket.ticketKey, "design-tokens.json", Buffer.from(JSON.stringify({ primary: "#FF6B35", spacing: "16px" }))); + await addAttachment(ticket.ticketKey, "wireframe.pdf", Buffer.from("%PDF-1.4\n1 0 obj<>endobj\n%%EOF\n")); + await addAttachment(ticket.ticketKey, "sizing-notes.txt", Buffer.from("Profile card should be 320px wide with 16px padding on all sides.\n")); + await addAttachment(ticket.ticketKey, "spec.md", Buffer.from("# Profile Card Spec\n\n## Requirements\n- Avatar: 64x64 circle\n- Name: 18px bold\n- Role: 14px muted\n")); + + console.log("Uploaded 5 attachments to", ticket.ticketKey); + console.log("Ticket will NOT be deleted — check it in Jira."); +} + +main().catch(console.error); diff --git a/e2e/scripts/delete-ticket.ts b/e2e/scripts/delete-ticket.ts new file mode 100644 index 0000000..c83261e --- /dev/null +++ b/e2e/scripts/delete-ticket.ts @@ -0,0 +1,10 @@ +import { deleteTicket } from "../helpers/jira.js"; + +const ticketKey = process.argv[2]; +if (!ticketKey) { + console.error("Usage: npx tsx --env-file=.env.e2e e2e/scripts/delete-ticket.ts "); + process.exit(1); +} + +await deleteTicket(ticketKey); +console.log(`Deleted ${ticketKey}`); diff --git a/e2e/tier1/cron-poll.test.ts b/e2e/tier1/cron-poll.test.ts deleted file mode 100644 index dfc4382..0000000 --- a/e2e/tier1/cron-poll.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - deleteTicket, -} from "../helpers/jira.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { cleanup as redisCleanup } from "../helpers/redis.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -describe("cron poll", () => { - let ticketKey: string; - - afterAll(async () => { - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("discovers tickets in the AI column", async () => { - const ticket = await createTestTicket(); - ticketKey = ticket.ticketKey; - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // Jira's JQL index can lag a few seconds after status changes - const { status, body } = await waitFor( - async () => { - const res = await callCronPoll(); - if (res.status === 200 && res.body.discovered >= 1) return res; - return null; - }, - { description: "cron poll discovers ticket", timeoutMs: 30_000, intervalMs: 3_000 }, - ); - - expect(status).toBe(200); - expect(body.status).toBe("ok"); - expect(body.discovered).toBeGreaterThanOrEqual(1); - }); - - it("rejects unauthenticated requests", async () => { - const { status } = await callCronPoll({ omitAuth: true }); - expect(status).toBe(401); - }); -}); diff --git a/e2e/tier1/cron-reconciliation.test.ts b/e2e/tier1/cron-reconciliation.test.ts deleted file mode 100644 index 4646d81..0000000 --- a/e2e/tier1/cron-reconciliation.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { callCronPoll } from "../helpers/cron.js"; -import { - setEntry, - getRunId, - cleanup as redisCleanup, -} from "../helpers/redis.js"; - -describe("cron reconciliation", () => { - // Use a ticket key that definitely doesn't exist in the AI column - const fakeTicketKey = `E2E-STALE-${Date.now()}`; - const fakeRunId = "stale-run-id-for-reconciliation"; - - afterAll(async () => { - await redisCleanup(fakeTicketKey); - }); - - it("cleans up stale Redis entries for tickets not in AI column", async () => { - // Insert a fake stale entry directly into Redis - await setEntry(fakeTicketKey, fakeRunId); - - // Verify it's there - const before = await getRunId(fakeTicketKey); - expect(before).toBe(fakeRunId); - - // Trigger poll — it should reconcile and clean up the stale entry - const { status, body } = await callCronPoll(); - - expect(status).toBe(200); - expect(body.status).toBe("ok"); - expect(body.cancelled + body.cleaned).toBeGreaterThanOrEqual(1); - - // The real assertion: Redis entry is gone after reconciliation - const after = await getRunId(fakeTicketKey); - expect(after).toBeNull(); - }); -}); diff --git a/e2e/tier2/clarification-flow.test.ts b/e2e/tier2/clarification-flow.test.ts deleted file mode 100644 index e09b68a..0000000 --- a/e2e/tier2/clarification-flow.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - getTicketComments, - deleteTicket, -} from "../helpers/jira.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { deleteBranch } from "../helpers/github.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -describe("clarification flow", () => { - let ticketKey: string; - let branchName: string; - - afterAll(async () => { - if (branchName) await deleteBranch(branchName); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("moves a vague ticket to Backlog with clarification questions", async () => { - const ticket = await createTestTicket({ - summary: `[E2E] Do the thing`, - description: "Do the thing", - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // Move to AI column and dispatch via cron poll - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - const { body } = await waitFor( - async () => { - const res = await callCronPoll(); - if (res.status === 200 && res.body.started?.includes(ticketKey)) return res; - return null; - }, - { description: `cron dispatches ${ticketKey}`, timeoutMs: 30_000, intervalMs: 3_000 }, - ); - expect(body.status).toBe("ok"); - - // Wait for ticket to move to Backlog (clarification needed) - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_BACKLOG ? status : null; - }, - { - description: `ticket ${ticketKey} moved to ${e2eEnv.COLUMN_BACKLOG}`, - timeoutMs: 2_100_000, - }, - ); - - // Verify ticket has a comment with questions - const comments = await getTicketComments(ticketKey); - const clarificationComment = comments.find( - (c) => /\d+\./.test(c.body), // Contains numbered items like "1. ..." - ); - expect(clarificationComment).toBeDefined(); - - // Verify Redis entry is cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { - description: `Redis entry cleaned for ${ticketKey}`, - timeoutMs: 30_000, - }, - ); - }); -}); diff --git a/e2e/tier2/stop-hook-commit.test.ts b/e2e/tier2/stop-hook-commit.test.ts deleted file mode 100644 index 12d79ce..0000000 --- a/e2e/tier2/stop-hook-commit.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { e2eEnv } from "../env.js"; - -/** - * Verifies the Stop hook forces Claude Code to commit uncommitted changes - * before exiting in --print mode. - * - * Flow: - * 1. Create a sandbox from the e2e repo - * 2. Install Claude Code + configure the Stop hook - * 3. Create an uncommitted file - * 4. Run Claude Code with a simple prompt (--print mode) - * 5. Assert git status is clean (hook forced a commit) - */ -describe("Stop hook commit guard", () => { - let sandbox: any; - - afterAll(async () => { - if (sandbox) await sandbox.stop().catch(() => {}); - }); - - it("forces Claude Code to commit uncommitted changes before stopping", async () => { - const { Sandbox } = await import("@vercel/sandbox"); - - sandbox = await Sandbox.create({ - source: { - type: "git", - url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`, - username: "x-access-token", - password: e2eEnv.E2E_GITHUB_TOKEN, - revision: "main", - depth: 1, - }, - runtime: "node24", - timeout: 300_000, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "", - ...(process.env.CLAUDE_CODE_OAUTH_TOKEN - ? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN } - : {}), - }, - }); - - // 1. Configure git identity - await sandbox.runCommand("bash", [ - "-c", - 'git config user.name "test-bot" && git config user.email "test@test.com"', - ]); - - // 2. Install Claude Code - await sandbox.runCommand("npm", [ - "install", - "-g", - "@anthropic-ai/claude-code", - ]); - - // 3. Set up the Stop hook (same as SandboxManager.provision) - await sandbox.runCommand("bash", [ - "-c", - [ - `mkdir -p ~/.claude`, - `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, - `#!/bin/bash`, - `input=$(cat)`, - `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, - `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/' | grep -v 'requirements\\.md')`, - `if [ -n "$changes" ]; then`, - ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, - ` exit 2`, - `fi`, - `SCRIPT`, - `chmod +x ~/.claude/commit-guard.sh`, - `cat > ~/.claude/settings.json << 'JSON'`, - `{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`, - `JSON`, - ].join("\n"), - ]); - - // 4. Skip onboarding (if using OAuth token) - if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { - await sandbox.runCommand("bash", [ - "-c", - `echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, - ]); - } - - // 5. Create an uncommitted file (simulates agent work without committing) - await sandbox.runCommand("bash", [ - "-c", - 'echo "test content" > test-uncommitted.txt', - ]); - - // Verify the file is uncommitted - const beforeStatus = await sandbox.runCommand("git", [ - "status", - "--porcelain", - ]); - const beforeOutput = (await beforeStatus.stdout()).trim(); - expect(beforeOutput).toContain("test-uncommitted.txt"); - - // 6. Run Claude Code — the prompt must instruct it to commit any uncommitted changes - const result = await sandbox.runCommand("bash", [ - "-c", - 'echo "Commit all uncommitted changes with a descriptive commit message, then exit." | claude --print --dangerously-skip-permissions', - ]); - const stdout = (await result.stdout()).trim(); - const stderr = (await result.stderr()).trim(); - - console.log("Claude stdout:", stdout); - console.log("Claude stderr:", stderr); - - // 7. Check git status — should be clean if the hook worked - const afterStatus = await sandbox.runCommand("git", [ - "status", - "--porcelain", - ]); - const afterOutput = (await afterStatus.stdout()).trim(); - - expect(afterOutput).toBe(""); - }); -}); diff --git a/e2e/tier2/us1-clear-ticket-pr.test.ts b/e2e/tier2/us1-clear-ticket-pr.test.ts index bbd5152..087e21e 100644 --- a/e2e/tier2/us1-clear-ticket-pr.test.ts +++ b/e2e/tier2/us1-clear-ticket-pr.test.ts @@ -8,6 +8,8 @@ import { import { findPR, getPRCommits, + getPRFiles, + getFileContent, closePR, deleteBranch, } from "../helpers/github.js"; @@ -39,16 +41,18 @@ describe("US-1: Clear ticket produces a PR", () => { }); it("implements a clear ticket and creates a PR on the correct branch", async () => { - // 1. Create ticket with clear, concrete requirements + // 1. Create ticket with very specific requirements so we can validate the output const ticket = await createTestTicket({ summary: "[E2E] Add GET /api/health endpoint", description: [ - "Create a GET /api/health route that returns { status: \"ok\" } with HTTP 200.", + "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", "", "Acceptance criteria:", - "- Returns JSON { status: \"ok\" }", + '- Route file at app/api/health/route.ts', + '- Exports a GET handler', + '- Returns JSON response: { status: "ok" }', "- HTTP 200 response", - "- Create only one route file", + "- No other files created or modified", ].join("\n"), }); ticketKey = ticket.ticketKey; @@ -56,22 +60,34 @@ describe("US-1: Clear ticket produces a PR", () => { // 2. Move to AI column — webhook or cron triggers dispatch await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // Poke cron to ensure dispatch if webhook didn't fire await callCronPoll(); - // 3. Wait for PR to appear on the expected branch (the definitive signal) + // 3. Wait for PR to appear on the expected branch const pr = await waitFor(() => findPR(branchName), { description: `PR on branch ${branchName}`, timeoutMs: 2_000_000, }); prNumber = pr.number; - // 4. Verify: PR has at least 1 commit + // 4. PR has at least 1 commit const commits = await getPRCommits(prNumber); expect(commits.length).toBeGreaterThan(0); - // 5. Verify: ticket moved to AI Review + // 5. PR contains the health route file + const prFiles = await getPRFiles(prNumber); + const filenames = prFiles.map((f) => f.filename); + expect(filenames.some((f) => f.includes("health/route"))).toBe(true); + + // 6. Route file exports a GET handler and returns { status: "ok" } + const routeContent = await getFileContent( + branchName, + "app/api/health/route.ts", + ); + expect(routeContent).not.toBeNull(); + expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); + expect(routeContent).toContain('"ok"'); + + // 7. Ticket moved to AI Review await waitFor( async () => { const status = await getTicketStatus(ticketKey); @@ -80,7 +96,7 @@ describe("US-1: Clear ticket produces a PR", () => { { description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_AI_REVIEW}`, timeoutMs: 60_000 }, ); - // 6. Verify: Redis entry cleaned up (no active run) + // 8. Redis entry cleaned up await waitFor( async () => { const runId = await getRunId(ticketKey); diff --git a/e2e/tier2/us2-attachments.test.ts b/e2e/tier2/us2-attachments.test.ts index 3527024..8484e2b 100644 --- a/e2e/tier2/us2-attachments.test.ts +++ b/e2e/tier2/us2-attachments.test.ts @@ -2,20 +2,18 @@ import { describe, it, expect, afterAll } from "vitest"; import { createTestTicket, addAttachment, - getTicketAttachments, - downloadJiraAttachment, deleteTicket, } from "../helpers/jira.js"; import { e2eEnv } from "../env.js"; /** - * US-2: Ticket with attachments (integration test: fetch + write phase) + * US-2: Ticket with attachments — real fetch + write pipeline * - * Tests that attachments on a Jira ticket can be downloaded and written - * to a sandbox at the expected paths. NOT a full E2E workflow — tests - * only the attachment fetch and write phases. + * Creates a ticket in Backlog with various attachment types (PNG, JSON, PDF, + * TXT, MD), then uses the production JiraAdapter + fetchAttachmentsWithRetry + * + sandbox writeFiles pipeline to verify the full attachment flow end-to-end. */ -describe("US-2: Ticket with attachments (fetch + write phase)", () => { +describe("US-2: Ticket with attachments (real pipeline)", () => { let ticketKey: string; let sandbox: { stop: () => Promise } | undefined; @@ -24,42 +22,88 @@ describe("US-2: Ticket with attachments (fetch + write phase)", () => { if (ticketKey) await deleteTicket(ticketKey); }); - it("downloads attachments from Jira and writes them to a sandbox", async () => { - // 1. Create a ticket + it("fetches attachments via JiraAdapter and writes them to a sandbox", async () => { + // 1. Create a ticket (stays in Backlog — no workflow triggered) const ticket = await createTestTicket({ summary: "[E2E] Create user profile card component", description: - "Build a profile card component matching the attached mockup.", + "Build a profile card component matching the attached mockup and specs.", }); ticketKey = ticket.ticketKey; - // 2. Upload test attachments to Jira - const mockupContent = Buffer.alloc(1024, 0x89); // 1 KB placeholder + // 2. Upload test attachments of various types to Jira + const mockupContent = Buffer.alloc(1024, 0x89); // 1 KB binary placeholder const tokensContent = Buffer.from( JSON.stringify({ primary: "#FF6B35", spacing: "16px" }), ); + const pdfContent = Buffer.from( + "%PDF-1.4\n1 0 obj<>endobj\n%%EOF\n", + ); + const txtContent = Buffer.from( + "Profile card should be 320px wide with 16px padding on all sides.\n", + ); + const mdContent = Buffer.from( + [ + "# Profile Card Spec", + "", + "## Requirements", + "- Avatar: 64x64 circle", + "- Name: 18px bold", + "- Role: 14px muted", + "", + ].join("\n"), + ); await addAttachment(ticketKey, "profile-mockup.png", mockupContent); await addAttachment(ticketKey, "design-tokens.json", tokensContent); + await addAttachment(ticketKey, "wireframe.pdf", pdfContent); + await addAttachment(ticketKey, "sizing-notes.txt", txtContent); + await addAttachment(ticketKey, "spec.md", mdContent); - // 3. Fetch attachment metadata — verify count - const attachments = await getTicketAttachments(ticketKey); - expect(attachments).toHaveLength(2); + // 3. Use the real JiraAdapter to fetch the ticket (like the workflow does) + const { JiraAdapter } = await import( + "../../src/adapters/issue-tracker/jira.js" + ); + const jira = new JiraAdapter({ + baseUrl: e2eEnv.JIRA_BASE_URL, + email: e2eEnv.JIRA_EMAIL, + apiToken: e2eEnv.JIRA_API_TOKEN, + projectKey: e2eEnv.JIRA_PROJECT_KEY, + }); - // 4. Download each attachment — verify content is non-empty - const downloaded = await Promise.all( - attachments.map(async (att) => { - const content = await downloadJiraAttachment(att.contentUrl); - return { filename: att.filename, content, size: content.length }; - }), + const ticketData = await jira.fetchTicket(ticketKey); + expect(ticketData.attachments).toHaveLength(5); + + // 4. Use the real fetchAttachmentsWithRetry to download (like the workflow does) + const { fetchAttachmentsWithRetry } = await import( + "../../src/sandbox/attachments.js" + ); + const log = { + info: () => {}, + warn: () => {}, + }; + + const downloaded = await fetchAttachmentsWithRetry( + jira, + ticketData.attachments, + { + maxFileSizeBytes: 10 * 1024 * 1024, + maxTotalSizeBytes: 50 * 1024 * 1024, + maxCount: 20, + downloadTimeoutMs: 30_000, + }, + log, ); - for (const d of downloaded) { - expect(d.size).toBeGreaterThan(0); + + // All 5 attachments downloaded successfully + const succeeded = downloaded.filter((a) => !a.failed); + expect(succeeded).toHaveLength(5); + for (const a of succeeded) { + expect(a.content).toBeDefined(); + expect(a.content!.length).toBeGreaterThan(0); } - expect(downloaded.find((d) => d.filename === "profile-mockup.png")).toBeDefined(); - expect(downloaded.find((d) => d.filename === "design-tokens.json")).toBeDefined(); - // 5. Create a sandbox and write files to /tmp/attachments/ + // 5. Create a sandbox and write files using the same pattern as the workflow const { Sandbox } = await import("@vercel/sandbox"); const sbx = await Sandbox.create({ source: { @@ -75,27 +119,32 @@ describe("US-2: Ticket with attachments (fetch + write phase)", () => { }); sandbox = sbx; + // Write files the same way the real writeAttachments step does + const toWrite = succeeded.filter((a) => a.content); await sbx.runCommand("mkdir", ["-p", "/tmp/attachments"]); await sbx.writeFiles( - downloaded.map((d) => ({ - path: `/tmp/attachments/${d.filename}`, - content: d.content, + toWrite.map((a) => ({ + path: `/tmp/attachments/${a.filename}`, + content: Buffer.isBuffer(a.content) + ? a.content + : Buffer.from(a.content as unknown as Uint8Array), })), ); - // 6. Verify: files exist at expected paths inside the sandbox - for (const d of downloaded) { + // 6. Verify: all files exist at expected paths + for (const a of toWrite) { const result = await sbx.runCommand("test", [ "-f", - `/tmp/attachments/${d.filename}`, + `/tmp/attachments/${a.filename}`, ]); expect(result.exitCode).toBe(0); } - // 7. Verify: file contents are valid (not corrupted) + // 7. Verify: binary file (PNG) preserved exact size + const pngFile = toWrite.find((a) => a.originalFilename === "profile-mockup.png")!; const pngStat = await sbx.runCommand("wc", [ "-c", - "/tmp/attachments/profile-mockup.png", + `/tmp/attachments/${pngFile.filename}`, ]); const pngSize = parseInt( (await pngStat.stdout()).trim().split(/\s+/)[0], @@ -103,11 +152,38 @@ describe("US-2: Ticket with attachments (fetch + write phase)", () => { ); expect(pngSize).toBe(mockupContent.length); + // 8. Verify: JSON file is valid and has correct content + const jsonFile = toWrite.find((a) => a.originalFilename === "design-tokens.json")!; const jsonResult = await sbx.runCommand("cat", [ - "/tmp/attachments/design-tokens.json", + `/tmp/attachments/${jsonFile.filename}`, ]); const jsonContent = (await jsonResult.stdout()).trim(); expect(() => JSON.parse(jsonContent)).not.toThrow(); expect(JSON.parse(jsonContent).primary).toBe("#FF6B35"); + + // 9. Verify: PDF file starts with PDF header + const pdfFile = toWrite.find((a) => a.originalFilename === "wireframe.pdf")!; + const pdfResult = await sbx.runCommand("head", [ + "-c", + "5", + `/tmp/attachments/${pdfFile.filename}`, + ]); + expect((await pdfResult.stdout()).trim()).toBe("%PDF-"); + + // 10. Verify: TXT file content matches + const txtFile = toWrite.find((a) => a.originalFilename === "sizing-notes.txt")!; + const txtResult = await sbx.runCommand("cat", [ + `/tmp/attachments/${txtFile.filename}`, + ]); + expect((await txtResult.stdout()).trim()).toContain("320px wide"); + + // 11. Verify: MD file content matches + const mdFile = toWrite.find((a) => a.originalFilename === "spec.md")!; + const mdResult = await sbx.runCommand("cat", [ + `/tmp/attachments/${mdFile.filename}`, + ]); + const mdOutput = (await mdResult.stdout()).trim(); + expect(mdOutput).toContain("# Profile Card Spec"); + expect(mdOutput).toContain("64x64 circle"); }); }); diff --git a/e2e/tier2/us3-review-fix-cycle.test.ts b/e2e/tier2/us3-review-fix-cycle.test.ts index a65630e..dd131f8 100644 --- a/e2e/tier2/us3-review-fix-cycle.test.ts +++ b/e2e/tier2/us3-review-fix-cycle.test.ts @@ -12,6 +12,7 @@ import { findPR, getPRCommits, getPRFiles, + getFileContent, addPRComment, closePR, deleteBranch, @@ -53,8 +54,14 @@ describe("US-3: Review feedback triggers a fix cycle", () => { const ticket = await createTestTicket({ summary: "[E2E] Add GET /api/ping endpoint", description: [ - "Add a GET /api/ping API route that returns { ping: 'pong' } with status 200.", - "Create only one route file at app/api/ping/route.ts.", + "Add a GET /api/ping API route that returns JSON { ping: 'pong' } with status 200.", + "", + "Acceptance criteria:", + "- Route file at app/api/ping/route.ts", + "- Exports a GET handler function", + '- Returns JSON response: { ping: "pong" }', + "- HTTP 200 response", + "- No other files created or modified", ].join("\n"), }); ticketKey = ticket.ticketKey; @@ -86,10 +93,16 @@ describe("US-3: Review feedback triggers a fix cycle", () => { const commitsBefore = await getPRCommits(prNumber); const commitCountBefore = commitsBefore.length; - // Add a review comment requesting a rename + // Add a review comment requesting a rename with specific instructions await addPRComment( prNumber, - 'Rename the `/ping` endpoint to `/healthcheck` — remove the old `/ping` route entirely and create `/healthcheck` instead.', + [ + "Please make these changes:", + "1. Delete app/api/ping/route.ts entirely", + "2. Create app/api/healthcheck/route.ts instead", + '3. The new route must export a GET handler that returns JSON { healthcheck: "passed" }', + "4. No other files should be created or modified", + ].join("\n"), ); // --- Act: move ticket to AI to trigger the review-fix workflow --- @@ -128,6 +141,19 @@ describe("US-3: Review feedback triggers a fix cycle", () => { expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); expect(filenames.some((f) => f.includes("/ping/"))).toBe(false); + // Healthcheck route file exists on the branch with correct content + const routeContent = await getFileContent( + branchName, + "app/api/healthcheck/route.ts", + ); + expect(routeContent).not.toBeNull(); + expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); + expect(routeContent).toContain('"passed"'); + + // Old ping route must not exist on the branch + const oldRoute = await getFileContent(branchName, "app/api/ping/route.ts"); + expect(oldRoute).toBeNull(); + // Redis cleaned up await waitFor( async () => { diff --git a/e2e/tier2/us4-merge-conflict-rebase.test.ts b/e2e/tier2/us4-merge-conflict-rebase.test.ts index f265900..6c358d4 100644 --- a/e2e/tier2/us4-merge-conflict-rebase.test.ts +++ b/e2e/tier2/us4-merge-conflict-rebase.test.ts @@ -10,6 +10,7 @@ import { createOrUpdateFile, openPR, getPRCommits, + getFileContent, isPRMergeable, closePR, deleteBranch, @@ -59,11 +60,13 @@ describe("US-4: PR with merge conflicts — agent rebases", () => { const ticket = await createTestTicket({ summary: `[E2E] Add greeting file at ${conflictFile}`, description: [ - `Create a file at ${conflictFile} containing exactly: "Hello from blazebot"`, + `Create a file at ${conflictFile} with a single line containing exactly: Hello from blazebot`, "", "Acceptance criteria:", - `- File exists at ${conflictFile}`, - '- File content is exactly "Hello from blazebot"', + `- File exists at path ${conflictFile}`, + "- File contains exactly one line: Hello from blazebot", + "- No other text or content in the file", + "- No other files created or modified", ].join("\n"), }); ticketKey = ticket.ticketKey; @@ -147,6 +150,13 @@ describe("US-4: PR with merge conflicts — agent rebases", () => { const commitsAfter = await getPRCommits(prNumber); expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); + // Conflict file on the branch contains the ticket's expected content + const fileContent = await getFileContent(branchName, conflictFile); + expect(fileContent).not.toBeNull(); + expect(fileContent!.trim()).toContain("Hello from blazebot"); + // Must not contain conflict markers + expect(fileContent).not.toMatch(/^<{7}/m); + // Ticket status is AI Review const finalStatus = await getTicketStatus(ticketKey); expect(finalStatus).toBe(e2eEnv.COLUMN_AI_REVIEW); From bc538deb3c71da6ba6ed3295f39ddbbd32c39b64 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 16 Apr 2026 15:59:37 +0200 Subject: [PATCH 36/71] fix: remove fake err --- e2e/helpers/github.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/e2e/helpers/github.ts b/e2e/helpers/github.ts index 08e5258..d10410d 100644 --- a/e2e/helpers/github.ts +++ b/e2e/helpers/github.ts @@ -1,7 +1,15 @@ import { Octokit } from "@octokit/rest"; import { e2eEnv } from "../env.js"; -const octokit = new Octokit({ auth: e2eEnv.E2E_GITHUB_TOKEN }); +const octokit = new Octokit({ + auth: e2eEnv.E2E_GITHUB_TOKEN, + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +}); const ownerRepo = { owner: e2eEnv.E2E_GITHUB_OWNER, repo: e2eEnv.E2E_GITHUB_REPO }; export async function findPR( From c33655f64c73556b760ea4af770b7210ff97712e Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 07:58:08 +0200 Subject: [PATCH 37/71] feat: add clarification e2e --- e2e/helpers/jira.ts | 21 +++ .../us5-unclear-ticket-clarification.test.ts | 100 +++++++++++ e2e/tier2/us6-clarification-answered.test.ts | 167 ++++++++++++++++++ src/workflows/agent.ts | 30 ++-- 4 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 e2e/tier2/us5-unclear-ticket-clarification.test.ts create mode 100644 e2e/tier2/us6-clarification-answered.test.ts diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index 2685999..5670e0e 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -100,6 +100,27 @@ export async function getTicketComments( })); } +export async function postComment( + ticketKey: string, + comment: string, +): Promise { + await jiraRequest(`/rest/api/3/issue/${ticketKey}/comment`, { + method: "POST", + body: JSON.stringify({ + body: { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: comment }], + }, + ], + }, + }), + }); +} + export async function deleteTicket(ticketKey: string): Promise { // Only delete tickets created by e2e tests const data = await jiraRequest( diff --git a/e2e/tier2/us5-unclear-ticket-clarification.test.ts b/e2e/tier2/us5-unclear-ticket-clarification.test.ts new file mode 100644 index 0000000..735cd4d --- /dev/null +++ b/e2e/tier2/us5-unclear-ticket-clarification.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + getTicketComments, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, deleteBranch } from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-5: Unclear ticket triggers clarification + * + * When a ticket is too vague/subjective to implement, the agent should + * return STATUS: clarification_needed, post numbered questions as a Jira + * comment, move the ticket to Backlog, and clean up Redis/sandbox. + */ +describe("US-5: Unclear ticket triggers clarification", () => { + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("asks clarification questions and moves the ticket to Backlog", async () => { + // 1. Create a deliberately vague ticket — subjective reference with + // no explicit target. This is exactly what the research prompt's + // clarity gate is designed to catch. + const ticket = await createTestTicket({ + summary: "[E2E] Change website color to my favorite color", + description: [ + "Update the primary brand color across the site to my favorite color.", + "", + "My favorite color is not specified anywhere in this ticket.", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Move to AI column — webhook or cron triggers dispatch + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + await callCronPoll(); + + // 3. Wait for the ticket to land in Backlog — the research phase is + // the only phase that runs in this path, so this is much faster + // than a full implementation. + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_BACKLOG ? status : null; + }, + { + description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG}`, + timeoutMs: 1_500_000, + }, + ); + + // 4. A Jira comment with numbered questions must have been posted. + // The workflow formats questions as "1. ...\n2. ..." via + // postClarificationAndMoveBack. + const comments = await getTicketComments(ticketKey); + const clarificationComment = comments.find((c) => + /^\s*1\.\s/m.test(c.body), + ); + expect(clarificationComment).toBeDefined(); + expect(clarificationComment!.body).toMatch(/^\s*1\.\s/m); + + // 5. No PR was created — clarification halts before implementation + const pr = await findPR(branchName); + expect(pr).toBeNull(); + + // 6. Redis entry cleaned up + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + ); + + // 7. No sandbox still running for this ticket + const stopped = await stopSandboxesForTicket(ticketKey); + expect(stopped).toBe(0); + + // 8. Final status assertion + const finalStatus = await getTicketStatus(ticketKey); + expect(finalStatus).toBe(e2eEnv.COLUMN_BACKLOG); + }); +}); diff --git a/e2e/tier2/us6-clarification-answered.test.ts b/e2e/tier2/us6-clarification-answered.test.ts new file mode 100644 index 0000000..ee4eed0 --- /dev/null +++ b/e2e/tier2/us6-clarification-answered.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + getTicketComments, + postComment, + deleteTicket, +} from "../helpers/jira.js"; +import { + findPR, + getPRCommits, + getFileContent, + closePR, + deleteBranch, +} from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-6: Clarification answered — ticket re-processed successfully [GitHub] + * + * After the agent asks a clarification question and moves the ticket to + * Backlog, the developer posts an answer as a Jira comment and moves the + * ticket back to AI. The research phase then reads the comment, the + * clarity gate passes, and the implementation proceeds to a PR. + * + * This covers two full workflow runs in sequence, so the per-test timeout + * is larger than the project default. + */ +describe("US-6: Clarification answered → ticket completes", () => { + // Unique value so the PR content check can't pass on pre-existing files + const uniqueGreeting = `Hello from Blazebot US-6 ${Date.now()}`; + let ticketKey: string; + let branchName: string; + let prNumber: number | undefined; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (prNumber) await closePR(prNumber); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it( + "uses the developer's answer from comments and implements the ticket", + async () => { + // --- Phase A: trigger clarification with a ticket missing a value --- + + const ticket = await createTestTicket({ + summary: "[E2E] Add greeting endpoint with my favorite greeting", + description: [ + "Create a GET /api/greeting route at app/api/greeting/route.ts", + "that returns JSON { message: X } with HTTP 200.", + "", + "The value of X is my favorite greeting. It is not specified in", + "this ticket — I will provide it in a follow-up comment.", + "", + "Acceptance criteria:", + "- Route file at app/api/greeting/route.ts", + "- Exports a GET handler", + "- Returns JSON: { message: X } where X is my favorite greeting", + "- HTTP 200 response", + "- No other files created or modified", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + await callCronPoll(); + + // Wait for Backlog (clarification path) — research-only, so fast + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_BACKLOG ? status : null; + }, + { + description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG} (clarification)`, + timeoutMs: 1_500_000, + }, + ); + + // Clarification comment must exist before we answer + const preAnswerComments = await getTicketComments(ticketKey); + const clarificationComment = preAnswerComments.find((c) => + /^\s*1\.\s/m.test(c.body), + ); + expect(clarificationComment).toBeDefined(); + + // Redis cleaned up after clarification before we restart + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { + description: `Redis clean after clarification for ${ticketKey}`, + timeoutMs: 30_000, + }, + ); + + // --- Phase B: developer answers + moves back to AI --- + + await postComment( + ticketKey, + `1. Use "${uniqueGreeting}" as the message value.`, + ); + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + await callCronPoll(); + + // Wait for AI Review — this time the full workflow runs + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; + }, + { + description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_AI_REVIEW} after answer`, + timeoutMs: 2_000_000, + }, + ); + + // --- Assert: PR created with the answered value --- + + const pr = await waitFor(() => findPR(branchName), { + description: `PR on branch ${branchName}`, + timeoutMs: 60_000, + }); + prNumber = pr.number; + + const commits = await getPRCommits(prNumber); + expect(commits.length).toBeGreaterThan(0); + + // The route file must exist on the branch and contain the answered + // greeting verbatim — proof the agent used the comment, not a guess. + const routeContent = await getFileContent( + branchName, + "app/api/greeting/route.ts", + ); + expect(routeContent).not.toBeNull(); + expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); + expect(routeContent).toContain(uniqueGreeting); + + // Redis cleaned up after the implementation run + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { + description: `Redis clean after implementation for ${ticketKey}`, + timeoutMs: 30_000, + }, + ); + }, + 4_200_000, // 70 min — two workflow runs back-to-back + ); +}); diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 42675af..732cf56 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -325,6 +325,12 @@ export async function agentWorkflow(ticketId: string) { const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return; + const phaseUsages: Record = {}; + const usageSuffix = () => + Object.keys(phaseUsages).length + ? `\n${formatUsageReport(phaseUsages)}` + : ""; + try { await notifySlack(`Task ${ticket.identifier} started`); @@ -392,13 +398,13 @@ export async function agentWorkflow(ticketId: string) { const researchDone = await pollUntilDone(sandboxId, "/tmp/research-done", 20); if (!researchDone) { await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: research phase timed out`); + await notifySlack(`Task ${ticket.identifier} failed: research phase timed out${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } const researchRaw = await collectPhaseOutput(sandboxId, "/tmp/research-stdout.txt", "/tmp/research-stderr.txt"); - const researchUsage = extractUsage(researchRaw); + phaseUsages["Research"] = extractUsage(researchRaw); const research = parseResearchStatus(unwrapResearchText(researchRaw)); if (research.status === "clarification_needed") { @@ -408,14 +414,14 @@ export async function agentWorkflow(ticketId: string) { questions.length > 0 ? questions : [research.body], env.COLUMN_BACKLOG, ); - await notifySlack(`Task ${ticket.identifier} needs clarification`); + await notifySlack(`Task ${ticket.identifier} needs clarification${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } if (research.status === "failed") { await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: research — ${research.body.slice(0, 200)}`); + await notifySlack(`Task ${ticket.identifier} failed: research — ${research.body.slice(0, 200)}${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } @@ -423,7 +429,6 @@ export async function agentWorkflow(ticketId: string) { const researchPlanMarkdown = research.body; // ========== PHASE 2: Implementation ========== - const phaseUsages: Record = { Research: researchUsage }; await configureStopHook(sandboxId, true); @@ -467,14 +472,14 @@ export async function agentWorkflow(ticketId: string) { implOutput.questions ?? [], env.COLUMN_BACKLOG, ); - await notifySlack(`Task ${ticket.identifier} needs clarification`); + await notifySlack(`Task ${ticket.identifier} needs clarification${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } if (implOutput.result === "failed") { await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}`); + await notifySlack(`Task ${ticket.identifier} failed: implementation — ${implOutput.error ?? "unknown"}${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } @@ -521,7 +526,7 @@ export async function agentWorkflow(ticketId: string) { if (reviewOutput.result === "failed") { await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}`); + await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } @@ -534,7 +539,7 @@ export async function agentWorkflow(ticketId: string) { if (!pushResult.pushed) { await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}`); + await notifySlack(`Task ${ticket.identifier} failed: push failed — ${pushResult.error ?? "unknown"}${usageSuffix()}`); await unregisterRun(ticket.identifier); return; } @@ -542,9 +547,12 @@ export async function agentWorkflow(ticketId: string) { if (!prContext) { await createPullRequest(branchName, ticket.title, ""); } - await moveTicket(ticketId, env.COLUMN_AI_REVIEW); + // Notify Slack BEFORE moving the ticket out of the AI column. + // Reconcile cancels runs whose tickets have left AI column; racing + // that cancellation after moveTicket would skip the notification. const usageReport = formatUsageReport(phaseUsages); await notifySlack(`Task ${ticket.identifier} PR ready for review\n${usageReport}`); + await moveTicket(ticketId, env.COLUMN_AI_REVIEW); await unregisterRun(ticket.identifier); } finally { await teardownSandbox(sandboxId); @@ -552,7 +560,7 @@ export async function agentWorkflow(ticketId: string) { } catch (err) { console.error(`Workflow failed for ${ticket.identifier}:`, err); const moved = await moveTicket(ticketId, env.COLUMN_BACKLOG).then(() => true).catch(() => false); - await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}`).catch(() => {}); + await notifySlack(`Task ${ticket.identifier} failed: ${(err as Error).message ?? "unknown"}${usageSuffix()}`).catch(() => {}); if (moved) { await unregisterRun(ticket.identifier).catch(() => {}); } else { From 7e614c467b7f26f0976fd43f892062492c748fc3 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 08:59:06 +0200 Subject: [PATCH 38/71] feat: add failure e2e tests --- e2e/helpers/redis.ts | 23 +++++ e2e/helpers/sandbox.ts | 38 +++++++ e2e/tier2/us7-agent-failure-backlog.test.ts | 103 +++++++++++++++++++ e2e/tier2/us8-previously-failed-skip.test.ts | 92 +++++++++++++++++ e2e/tier2/us9-failed-marker-cleared.test.ts | 78 ++++++++++++++ 5 files changed, 334 insertions(+) create mode 100644 e2e/tier2/us7-agent-failure-backlog.test.ts create mode 100644 e2e/tier2/us8-previously-failed-skip.test.ts create mode 100644 e2e/tier2/us9-failed-marker-cleared.test.ts diff --git a/e2e/helpers/redis.ts b/e2e/helpers/redis.ts index ffc065e..2a8675c 100644 --- a/e2e/helpers/redis.ts +++ b/e2e/helpers/redis.ts @@ -2,6 +2,7 @@ import { Redis } from "@upstash/redis"; import { e2eEnv } from "../env.js"; const HASH_KEY = `blazebot:active-runs:${e2eEnv.VERCEL_ENV}`; +const FAILED_HASH_KEY = `blazebot:failed-tickets:${e2eEnv.VERCEL_ENV}`; const redis = new Redis({ url: e2eEnv.AI_WORKFLOW_KV_REST_API_URL, @@ -34,3 +35,25 @@ export async function setEntry( export async function cleanup(ticketKey: string): Promise { await redis.hdel(HASH_KEY, ticketKey).catch(() => {}); } + +export interface FailedTicketMeta { + runId: string; + error: string; + failedAt: string; +} + +export async function markFailed( + ticketKey: string, + meta: FailedTicketMeta, +): Promise { + await redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); +} + +export async function isTicketFailed(ticketKey: string): Promise { + const value = await redis.hget(FAILED_HASH_KEY, ticketKey); + return value != null; +} + +export async function cleanupFailed(ticketKey: string): Promise { + await redis.hdel(FAILED_HASH_KEY, ticketKey).catch(() => {}); +} diff --git a/e2e/helpers/sandbox.ts b/e2e/helpers/sandbox.ts index 694a169..612ff09 100644 --- a/e2e/helpers/sandbox.ts +++ b/e2e/helpers/sandbox.ts @@ -40,3 +40,41 @@ export async function stopSandboxesForTicket( return 0; } } + +/** + * Kill the running `claude` process inside the ticket's sandbox. + * + * The wrapper script's cleanup section (touch sentinel) runs unconditionally + * after claude exits, so killing claude causes the workflow's pollUntilDone + * to see the sentinel with empty/partial stdout — parseResearchStatus then + * defaults to `{ status: "failed" }`, exercising the US-7 failure path. + */ +export async function killClaudeForTicket( + ticketKey: string, +): Promise { + const expectedBranch = `blazebot/${ticketKey.trim().toLowerCase()}`; + const { Sandbox } = await import("@vercel/sandbox"); + const { json } = await Sandbox.list({ limit: 100 }); + const running = json.sandboxes.filter( + (s: { status?: string }) => s.status === "running", + ); + + for (const entry of running) { + const sandbox = await Sandbox.get({ sandboxId: entry.id }); + if (sandbox.status !== "running") continue; + + const branchResult = await sandbox.runCommand({ + cmd: "git", + args: ["rev-parse", "--abbrev-ref", "HEAD"], + cwd: "/vercel/sandbox", + }); + const branch = branchResult.exitCode === 0 + ? (await branchResult.stdout()).trim() + : null; + if (branch !== expectedBranch) continue; + + await sandbox.runCommand({ cmd: "pkill", args: ["-9", "-f", "claude"] }); + return true; + } + return false; +} diff --git a/e2e/tier2/us7-agent-failure-backlog.test.ts b/e2e/tier2/us7-agent-failure-backlog.test.ts new file mode 100644 index 0000000..cee00f2 --- /dev/null +++ b/e2e/tier2/us7-agent-failure-backlog.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, deleteBranch } from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { + stopSandboxesForTicket, + killClaudeForTicket, +} from "../helpers/sandbox.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-7: Agent failure moves ticket to Backlog + * + * When the agent fails mid-run, the ticket should move to Backlog and all + * resources (Redis entry, sandbox) should be cleaned up. We simulate the + * failure by killing the claude process inside the research-phase sandbox — + * the wrapper script's cleanup still touches the sentinel, and + * parseResearchStatus defaults to `failed` on empty/partial stdout. + */ +describe("US-7: Agent failure moves ticket to Backlog", () => { + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("moves ticket to Backlog and cleans up when the agent fails", async () => { + // 1. Create a normal, clear ticket — would succeed on the happy path + const ticket = await createTestTicket({ + summary: "[E2E] Add GET /api/health endpoint", + description: [ + "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", + "", + "Acceptance criteria:", + "- Route file at app/api/health/route.ts", + "- Exports a GET handler", + '- Returns JSON response: { status: "ok" }', + "- HTTP 200 response", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Move to AI column — Jira webhook triggers dispatch + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // 3. Poll until the research-phase sandbox exists, then kill claude. + // killClaudeForTicket returns true once it finds and pkills the + // claude process in the sandbox matching this ticket's branch. + await waitFor(() => killClaudeForTicket(ticketKey), { + description: `sandbox ready to kill for ${ticketKey}`, + timeoutMs: 300_000, + intervalMs: 10_000, + }); + + // 4. Workflow's pollUntilDone picks up the sentinel within 30s, + // collectPhaseOutput reads empty stdout, parseResearchStatus + // defaults to `failed`, workflow moves ticket to Backlog. + await waitFor( + async () => { + const status = await getTicketStatus(ticketKey); + return status === e2eEnv.COLUMN_BACKLOG ? status : null; + }, + { + description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG}`, + timeoutMs: 300_000, + }, + ); + + // 5. No PR was created — failure halts before push + const pr = await findPR(branchName); + expect(pr).toBeNull(); + + // 6. Redis entry cleaned up + await waitFor( + async () => { + const runId = await getRunId(ticketKey); + return runId === null ? true : null; + }, + { description: `Redis clean for ${ticketKey}`, timeoutMs: 60_000 }, + ); + + // 7. No sandbox still running for this ticket + const stopped = await stopSandboxesForTicket(ticketKey); + expect(stopped).toBe(0); + + // 8. Final status assertion + const finalStatus = await getTicketStatus(ticketKey); + expect(finalStatus).toBe(e2eEnv.COLUMN_BACKLOG); + }); +}); diff --git a/e2e/tier2/us8-previously-failed-skip.test.ts b/e2e/tier2/us8-previously-failed-skip.test.ts new file mode 100644 index 0000000..61d0ddc --- /dev/null +++ b/e2e/tier2/us8-previously-failed-skip.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, deleteBranch } from "../helpers/github.js"; +import { + getRunId, + cleanup as redisCleanup, + markFailed, + isTicketFailed, + cleanupFailed, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-8: Previously-failed ticket is skipped on re-poll + * + * A ticket with a Redis failure marker must not be re-dispatched even while + * it sits in the AI column — the dispatch precheck returns + * `previously_failed` and no workflow is started. + * + * We seed the failure marker directly because its only production trigger is + * the workflow's catch-block safeguard (Jira unreachable during error + * recovery), which is impractical to force in e2e. + */ +describe("US-8: Previously-failed ticket is skipped", () => { + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await cleanupFailed(ticketKey); + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("does not dispatch a workflow for a ticket marked failed", async () => { + // 1. Create a clear ticket — would succeed if dispatched + const ticket = await createTestTicket({ + summary: "[E2E] Previously-failed skip guard", + description: "Clear ticket; this test verifies it is NOT dispatched.", + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Seed the failure marker in Redis (simulates the catch-block safeguard) + await markFailed(ticketKey, { + runId: "run_e2e_seeded", + error: "seeded by e2e test", + failedAt: new Date().toISOString(), + }); + + // 3. Move to AI column — Jira webhook triggers dispatch, which must skip + // because the failure marker is present. + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // 4. Give the webhook + dispatch precheck time to run, then assert that + // no active-run Redis entry was ever created. We poll for the full + // window rather than a single check to catch any claim that might + // appear mid-window (e.g. from a retry). + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const runId = await getRunId(ticketKey); + expect(runId).toBeNull(); + await new Promise((r) => setTimeout(r, 2_000)); + } + + // 5. Failure marker still present — reconcile only clears it when the + // ticket has *left* the AI column (US-9 covers that path) + expect(await isTicketFailed(ticketKey)).toBe(true); + + // 6. No PR and no sandbox for this ticket + const pr = await findPR(branchName); + expect(pr).toBeNull(); + const stopped = await stopSandboxesForTicket(ticketKey); + expect(stopped).toBe(0); + + // 7. Ticket remains in AI column (skipped, not moved). Jira returns the + // canonical display name, which may differ in case from COLUMN_AI — + // production code lowercases on both sides for comparison. + const status = await getTicketStatus(ticketKey); + expect(status.toLowerCase()).toBe(e2eEnv.COLUMN_AI.toLowerCase()); + }); +}); diff --git a/e2e/tier2/us9-failed-marker-cleared.test.ts b/e2e/tier2/us9-failed-marker-cleared.test.ts new file mode 100644 index 0000000..bcb92bd --- /dev/null +++ b/e2e/tier2/us9-failed-marker-cleared.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { deleteBranch } from "../helpers/github.js"; +import { + cleanup as redisCleanup, + markFailed, + isTicketFailed, + cleanupFailed, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-9: Failed marker is cleared when a ticket leaves the AI column + * + * Reconcile (part of the cron poll) lists all failure markers and clears any + * whose ticket is no longer in the AI column snapshot. After clearing, the + * ticket can be retried on a future re-entry into AI. + */ +describe("US-9: Failed marker cleared when ticket leaves AI", () => { + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await cleanupFailed(ticketKey); + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("clears the failure marker on reconcile when the ticket is not in AI", async () => { + // 1. Create a ticket and move it to Backlog — anything outside AI works. + const ticket = await createTestTicket({ + summary: "[E2E] Failed marker clears on reconcile", + description: + "Ticket sits outside AI; reconcile should clear the seeded failure marker.", + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); + + // 2. Seed a failure marker in Redis + await markFailed(ticketKey, { + runId: "run_e2e_seeded", + error: "seeded by e2e test", + failedAt: new Date().toISOString(), + }); + expect(await isTicketFailed(ticketKey)).toBe(true); + + // 3. Trigger cron — runs reconcileRuns which clears markers for tickets + // not in the AI column snapshot + const res = await callCronPoll(); + expect(res.status).toBe(200); + + // 4. Marker is cleared (allow a brief propagation window) + await waitFor( + async () => ((await isTicketFailed(ticketKey)) ? null : true), + { + description: `failure marker cleared for ${ticketKey}`, + timeoutMs: 30_000, + }, + ); + + // 5. Ticket is still in Backlog — reconcile never moves tickets + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + }); +}); From f246cd5c333af358bea64ffc8661659d07530142 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 10:25:17 +0200 Subject: [PATCH 39/71] feat: add dispatch e2e --- e2e/env.ts | 3 + e2e/helpers/webhook.ts | 63 +++++++++++++ .../us10-duplicate-dispatch-prevented.test.ts | 93 +++++++++++++++++++ ...2-ticket-moved-out-during-dispatch.test.ts | 85 +++++++++++++++++ .../us13-webhook-immediate-dispatch.test.ts | 89 ++++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 e2e/helpers/webhook.ts create mode 100644 e2e/tier2/us10-duplicate-dispatch-prevented.test.ts create mode 100644 e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts create mode 100644 e2e/tier2/us13-webhook-immediate-dispatch.test.ts diff --git a/e2e/env.ts b/e2e/env.ts index 0ab6c61..178a15d 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -18,6 +18,9 @@ const schema = z.object({ CRON_SECRET: z.string().min(1), + /** Only required by webhook-signing tests (US-12). */ + JIRA_WEBHOOK_SECRET: z.string().min(1).optional(), + AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), diff --git a/e2e/helpers/webhook.ts b/e2e/helpers/webhook.ts new file mode 100644 index 0000000..aab685e --- /dev/null +++ b/e2e/helpers/webhook.ts @@ -0,0 +1,63 @@ +import { createHmac } from "node:crypto"; +import { e2eEnv } from "../env.js"; + +export interface JiraWebhookPayload { + ticketKey: string; + status: string; + projectKey?: string; + webhookEvent?: string; +} + +/** + * Send a signed Jira webhook payload to the deployed /webhooks/jira endpoint. + * + * Useful for tests that need to exercise the webhook dispatch path with a + * controlled payload (e.g. simulating a stale status in the webhook body + * vs. the live ticket state). + */ +export async function postJiraWebhook( + payload: JiraWebhookPayload, +): Promise<{ status: number; body: any }> { + const secret = e2eEnv.JIRA_WEBHOOK_SECRET; + if (!secret) { + throw new Error( + "JIRA_WEBHOOK_SECRET is not set — required for webhook signing in US-12", + ); + } + + const body = JSON.stringify({ + webhookEvent: payload.webhookEvent ?? "jira:issue_updated", + issue: { + key: payload.ticketKey, + fields: { + status: { name: payload.status }, + project: { key: payload.projectKey ?? e2eEnv.JIRA_PROJECT_KEY }, + }, + }, + }); + + const signature = createHmac("sha256", secret).update(body, "utf8").digest("hex"); + + const headers: Record = { + "Content-Type": "application/json", + "X-Hub-Signature": `sha256=${signature}`, + }; + if (e2eEnv.VERCEL_AUTOMATION_BYPASS_SECRET) { + headers["x-vercel-protection-bypass"] = e2eEnv.VERCEL_AUTOMATION_BYPASS_SECRET; + } + + const res = await fetch(`${e2eEnv.E2E_BASE_URL}/webhooks/jira`, { + method: "POST", + headers, + body, + }); + + let responseBody: any; + try { + responseBody = await res.json(); + } catch { + responseBody = null; + } + + return { status: res.status, body: responseBody }; +} diff --git a/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts b/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts new file mode 100644 index 0000000..3d65214 --- /dev/null +++ b/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, deleteBranch } from "../helpers/github.js"; +import { + getRunId, + setEntry, + cleanup as redisCleanup, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-10: Duplicate dispatch prevented by atomic claim + * + * When a ticket is already claimed in Redis (another dispatch won the race), + * further dispatch attempts — from a re-fired webhook or an overlapping cron + * poll — must return `already_claimed` and MUST NOT start a second workflow. + * + * Simulating two truly concurrent HTTP dispatches is brittle at the e2e layer, + * so we seed a pre-existing claim in Redis and then trigger both dispatch + * paths (webhook via the Jira transition, and an explicit cron poll). Each + * path should observe the existing claim via HSETNX and skip. The atomic + * `claim` semantics themselves are exhaustively covered by the unit tests in + * `src/lib/dispatch.test.ts` ("only one concurrent dispatch wins when claim + * is atomic"). + */ +describe("US-10: Duplicate dispatch prevented by atomic claim", () => { + const SEEDED_RUN_ID = "run_e2e_us10_preexisting"; + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("skips dispatch when the ticket is already claimed in Redis", async () => { + // 1. Create a clear ticket — would succeed on the happy path if dispatched. + const ticket = await createTestTicket({ + summary: "[E2E] Duplicate dispatch guard", + description: "Clear ticket; this test verifies it is NOT dispatched twice.", + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Seed an active-run entry BEFORE the ticket reaches AI. The production + // `claim()` uses HSETNX, so any pre-existing value blocks future claims. + await setEntry(ticketKey, SEEDED_RUN_ID); + + // 3. Trigger the webhook-driven dispatch path by moving to AI. Jira fires + // the webhook; the handler calls dispatchTicket → claim() fails → + // returns already_claimed. Ticket stays in AI. + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // 4. Also trigger the cron-driven dispatch path explicitly. The cron + // discovers the ticket in AI and attempts dispatch — which must also + // skip for the same reason. + await callCronPoll(); + + // 5. Give both dispatch paths time to complete, then assert the Redis + // entry is unchanged throughout the window. If any dispatch had won + // the race it would overwrite the claim with a real runId. + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const runId = await getRunId(ticketKey); + expect(runId).toBe(SEEDED_RUN_ID); + await new Promise((r) => setTimeout(r, 2_000)); + } + + // 6. No PR was created — no workflow ever ran. + const pr = await findPR(branchName); + expect(pr).toBeNull(); + + // 7. No sandbox running for this ticket. + const stopped = await stopSandboxesForTicket(ticketKey); + expect(stopped).toBe(0); + + // 8. Ticket remains in AI (skipped, not moved). Jira may return the + // canonical display name in different casing — match case-insensitively. + const status = await getTicketStatus(ticketKey); + expect(status.toLowerCase()).toBe(e2eEnv.COLUMN_AI.toLowerCase()); + }); +}); diff --git a/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts b/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts new file mode 100644 index 0000000..baac45d --- /dev/null +++ b/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, afterAll, beforeAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, deleteBranch } from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { postJiraWebhook } from "../helpers/webhook.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-12: Ticket moved out of AI during dispatch + * + * Race: webhook payload says `status = AI`, but by the time the handler fetches + * the live ticket from Jira, it has already moved to another column. Dispatch + * must abort cleanly: fetch sees the live status is not AI, unregisters the + * claim, and returns `not_in_ai_column`. No workflow starts; no resources leak. + * + * We reproduce this deterministically by keeping the ticket in Backlog and + * hand-crafting a webhook payload that lies about the status. The handler + * signs its own HMAC check with `JIRA_WEBHOOK_SECRET`, then dispatch hits the + * live-status precheck and bails. + */ +describe("US-12: Ticket moved out of AI during dispatch", () => { + let ticketKey: string; + let branchName: string; + + beforeAll(() => { + if (!e2eEnv.JIRA_WEBHOOK_SECRET) { + throw new Error( + "US-12 requires JIRA_WEBHOOK_SECRET in the e2e env (matches deployed config).", + ); + } + }); + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("aborts dispatch when live ticket status is no longer AI", async () => { + // 1. Create a ticket and ensure it is NOT in AI — Backlog is the usual + // destination for freshly created tickets, but move explicitly to be + // defensive against project workflow changes. + const ticket = await createTestTicket({ + summary: "[E2E] Moved-out-of-AI race guard", + description: "Ticket never enters AI; webhook lies, dispatch must bail.", + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + + // 2. Fire a signed webhook claiming the ticket is in AI. The handler + // validates the signature, dispatches, then fetches the live ticket + // and sees it is actually in Backlog. + const { status, body } = await postJiraWebhook({ + ticketKey, + status: e2eEnv.COLUMN_AI, + }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + status: "skipped", + ticketKey, + reason: "not_in_ai_column", + }); + + // 3. Claim was released — no Redis entry for this ticket. + expect(await getRunId(ticketKey)).toBeNull(); + + // 4. No PR, no sandbox, ticket still in Backlog. + expect(await findPR(branchName)).toBeNull(); + expect(await stopSandboxesForTicket(ticketKey)).toBe(0); + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + }); +}); diff --git a/e2e/tier2/us13-webhook-immediate-dispatch.test.ts b/e2e/tier2/us13-webhook-immediate-dispatch.test.ts new file mode 100644 index 0000000..7beaf24 --- /dev/null +++ b/e2e/tier2/us13-webhook-immediate-dispatch.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, closePR, deleteBranch } from "../helpers/github.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-13: Webhook-triggered immediate dispatch + * + * When Jira fires a status-change webhook, dispatch must run immediately — + * not after the next cron tick. We assert this by moving the ticket to AI + * and watching for the Redis claim to appear well inside a single cron + * interval (cron runs every 60s; we bound this test at 30s). + * + * We intentionally do NOT call `callCronPoll()` — if the claim appears, it + * can only have come from the webhook path. + */ +describe("US-13: Webhook-triggered immediate dispatch", () => { + const WEBHOOK_WINDOW_MS = 30_000; + let ticketKey: string; + let branchName: string; + let prNumber: number | undefined; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (prNumber) await closePR(prNumber).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("claims the ticket within seconds of a Jira status-change webhook", async () => { + // 1. Create a clear ticket; we only care about dispatch latency, not the + // workflow result — but using a clear description keeps the agent from + // immediately bailing into a clarification path. + const ticket = await createTestTicket({ + summary: "[E2E] Webhook immediate dispatch", + description: [ + "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", + "", + "Acceptance criteria:", + "- Route file at app/api/health/route.ts", + "- Exports a GET handler", + '- Returns JSON response: { status: "ok" }', + "- HTTP 200 response", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + // 2. Move to AI — Jira fires the webhook. No cron poll is invoked, so any + // claim we observe must be attributable to the webhook path. + const start = Date.now(); + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // 3. The dispatch handler HSETNX's a `claiming:` sentinel first, then + // overwrites with the real runId once the workflow starts. Either is + // sufficient evidence that dispatch ran — fail fast at 30s, well short + // of a 60s cron cycle. + const runId = await waitFor( + async () => { + const current = await getRunId(ticketKey); + return current ?? null; + }, + { + description: `webhook-triggered claim for ${ticketKey}`, + timeoutMs: WEBHOOK_WINDOW_MS, + intervalMs: 1_000, + }, + ); + + const elapsedMs = Date.now() - start; + expect(runId).toBeTruthy(); + expect(elapsedMs).toBeLessThan(WEBHOOK_WINDOW_MS); + + // 4. Best-effort: capture the PR number if the workflow happens to finish + // before afterAll runs, so cleanup can close it cleanly. + const pr = await findPR(branchName).catch(() => null); + if (pr) prNumber = pr.number; + }); +}); From 9c6736067c8a0a9448e7194289549482cc1f4359 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 11:37:04 +0200 Subject: [PATCH 40/71] feat: update capacity check --- .../{us1-clear-ticket-pr.test.ts => us01-clear-ticket-pr.test.ts} | 0 e2e/tier2/{us2-attachments.test.ts => us02-attachments.test.ts} | 0 ...us3-review-fix-cycle.test.ts => us03-review-fix-cycle.test.ts} | 0 ...conflict-rebase.test.ts => us04-merge-conflict-rebase.test.ts} | 0 ...fication.test.ts => us05-unclear-ticket-clarification.test.ts} | 0 ...ation-answered.test.ts => us06-clarification-answered.test.ts} | 0 ...failure-backlog.test.ts => us07-agent-failure-backlog.test.ts} | 0 ...ly-failed-skip.test.ts => us08-previously-failed-skip.test.ts} | 0 ...-marker-cleared.test.ts => us09-failed-marker-cleared.test.ts} | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename e2e/tier2/{us1-clear-ticket-pr.test.ts => us01-clear-ticket-pr.test.ts} (100%) rename e2e/tier2/{us2-attachments.test.ts => us02-attachments.test.ts} (100%) rename e2e/tier2/{us3-review-fix-cycle.test.ts => us03-review-fix-cycle.test.ts} (100%) rename e2e/tier2/{us4-merge-conflict-rebase.test.ts => us04-merge-conflict-rebase.test.ts} (100%) rename e2e/tier2/{us5-unclear-ticket-clarification.test.ts => us05-unclear-ticket-clarification.test.ts} (100%) rename e2e/tier2/{us6-clarification-answered.test.ts => us06-clarification-answered.test.ts} (100%) rename e2e/tier2/{us7-agent-failure-backlog.test.ts => us07-agent-failure-backlog.test.ts} (100%) rename e2e/tier2/{us8-previously-failed-skip.test.ts => us08-previously-failed-skip.test.ts} (100%) rename e2e/tier2/{us9-failed-marker-cleared.test.ts => us09-failed-marker-cleared.test.ts} (100%) diff --git a/e2e/tier2/us1-clear-ticket-pr.test.ts b/e2e/tier2/us01-clear-ticket-pr.test.ts similarity index 100% rename from e2e/tier2/us1-clear-ticket-pr.test.ts rename to e2e/tier2/us01-clear-ticket-pr.test.ts diff --git a/e2e/tier2/us2-attachments.test.ts b/e2e/tier2/us02-attachments.test.ts similarity index 100% rename from e2e/tier2/us2-attachments.test.ts rename to e2e/tier2/us02-attachments.test.ts diff --git a/e2e/tier2/us3-review-fix-cycle.test.ts b/e2e/tier2/us03-review-fix-cycle.test.ts similarity index 100% rename from e2e/tier2/us3-review-fix-cycle.test.ts rename to e2e/tier2/us03-review-fix-cycle.test.ts diff --git a/e2e/tier2/us4-merge-conflict-rebase.test.ts b/e2e/tier2/us04-merge-conflict-rebase.test.ts similarity index 100% rename from e2e/tier2/us4-merge-conflict-rebase.test.ts rename to e2e/tier2/us04-merge-conflict-rebase.test.ts diff --git a/e2e/tier2/us5-unclear-ticket-clarification.test.ts b/e2e/tier2/us05-unclear-ticket-clarification.test.ts similarity index 100% rename from e2e/tier2/us5-unclear-ticket-clarification.test.ts rename to e2e/tier2/us05-unclear-ticket-clarification.test.ts diff --git a/e2e/tier2/us6-clarification-answered.test.ts b/e2e/tier2/us06-clarification-answered.test.ts similarity index 100% rename from e2e/tier2/us6-clarification-answered.test.ts rename to e2e/tier2/us06-clarification-answered.test.ts diff --git a/e2e/tier2/us7-agent-failure-backlog.test.ts b/e2e/tier2/us07-agent-failure-backlog.test.ts similarity index 100% rename from e2e/tier2/us7-agent-failure-backlog.test.ts rename to e2e/tier2/us07-agent-failure-backlog.test.ts diff --git a/e2e/tier2/us8-previously-failed-skip.test.ts b/e2e/tier2/us08-previously-failed-skip.test.ts similarity index 100% rename from e2e/tier2/us8-previously-failed-skip.test.ts rename to e2e/tier2/us08-previously-failed-skip.test.ts diff --git a/e2e/tier2/us9-failed-marker-cleared.test.ts b/e2e/tier2/us09-failed-marker-cleared.test.ts similarity index 100% rename from e2e/tier2/us9-failed-marker-cleared.test.ts rename to e2e/tier2/us09-failed-marker-cleared.test.ts From e2c7563326590d9d9672e77067990963b37500bc Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 11:37:12 +0200 Subject: [PATCH 41/71] feat: update capacity check --- e2e/env.ts | 6 + e2e/tier2/us01-clear-ticket-pr.test.ts | 2 +- e2e/tier2/us02-attachments.test.ts | 2 +- e2e/tier2/us03-review-fix-cycle.test.ts | 2 +- e2e/tier2/us04-merge-conflict-rebase.test.ts | 2 +- .../us05-unclear-ticket-clarification.test.ts | 2 +- e2e/tier2/us06-clarification-answered.test.ts | 2 +- e2e/tier2/us07-agent-failure-backlog.test.ts | 2 +- e2e/tier2/us08-previously-failed-skip.test.ts | 23 +++- e2e/tier2/us09-failed-marker-cleared.test.ts | 12 +- .../us11-capacity-limit-respected.test.ts | 125 ++++++++++++++++++ src/lib/dispatch.test.ts | 103 ++++++--------- src/lib/dispatch.ts | 90 ++++++------- src/routes/webhooks/jira.post.ts | 8 +- 14 files changed, 250 insertions(+), 131 deletions(-) create mode 100644 e2e/tier2/us11-capacity-limit-respected.test.ts diff --git a/e2e/env.ts b/e2e/env.ts index 178a15d..afb1534 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -28,6 +28,12 @@ const schema = z.object({ VERCEL_ENV: z.string().min(1).default("preview"), VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), + + /** + * Must match the deployed app's MAX_CONCURRENT_AGENTS. US-11 creates + * this many dummy sandboxes to saturate the dispatch capacity check. + */ + MAX_CONCURRENT_AGENTS: z.coerce.number().int().positive().default(3), }); export type E2EEnv = z.infer; diff --git a/e2e/tier2/us01-clear-ticket-pr.test.ts b/e2e/tier2/us01-clear-ticket-pr.test.ts index 087e21e..3bb7547 100644 --- a/e2e/tier2/us01-clear-ticket-pr.test.ts +++ b/e2e/tier2/us01-clear-ticket-pr.test.ts @@ -25,7 +25,7 @@ import { e2eEnv } from "../env.js"; * When a ticket with clear requirements is moved to the AI column, * the agent implements the feature and creates a PR for review. */ -describe("US-1: Clear ticket produces a PR", () => { +describe("US-01: Clear ticket produces a PR", () => { let ticketKey: string; let branchName: string; let prNumber: number | undefined; diff --git a/e2e/tier2/us02-attachments.test.ts b/e2e/tier2/us02-attachments.test.ts index 8484e2b..0eab49d 100644 --- a/e2e/tier2/us02-attachments.test.ts +++ b/e2e/tier2/us02-attachments.test.ts @@ -13,7 +13,7 @@ import { e2eEnv } from "../env.js"; * TXT, MD), then uses the production JiraAdapter + fetchAttachmentsWithRetry * + sandbox writeFiles pipeline to verify the full attachment flow end-to-end. */ -describe("US-2: Ticket with attachments (real pipeline)", () => { +describe("US-02: Ticket with attachments (real pipeline)", () => { let ticketKey: string; let sandbox: { stop: () => Promise } | undefined; diff --git a/e2e/tier2/us03-review-fix-cycle.test.ts b/e2e/tier2/us03-review-fix-cycle.test.ts index dd131f8..07d1127 100644 --- a/e2e/tier2/us03-review-fix-cycle.test.ts +++ b/e2e/tier2/us03-review-fix-cycle.test.ts @@ -33,7 +33,7 @@ import { e2eEnv } from "../env.js"; * Setup uses GitHub API to create branch + code + PR in seconds, * instead of waiting for a full workflow run. */ -describe("US-3: Review feedback triggers a fix cycle", () => { +describe("US-03: Review feedback triggers a fix cycle", () => { let ticketKey: string; let branchName: string; let prNumber: number | undefined; diff --git a/e2e/tier2/us04-merge-conflict-rebase.test.ts b/e2e/tier2/us04-merge-conflict-rebase.test.ts index 6c358d4..fc6eb93 100644 --- a/e2e/tier2/us04-merge-conflict-rebase.test.ts +++ b/e2e/tier2/us04-merge-conflict-rebase.test.ts @@ -32,7 +32,7 @@ import { e2eEnv } from "../env.js"; * Setup uses GitHub API to create a branch, add a file, add a * CONFLICTING file on main, then create a PR that shows conflicts. */ -describe("US-4: PR with merge conflicts — agent rebases", () => { +describe("US-04: PR with merge conflicts — agent rebases", () => { const uniqueDir = `blazebot-e2e-${Date.now()}`; const conflictFile = `${uniqueDir}/data.txt`; let ticketKey: string; diff --git a/e2e/tier2/us05-unclear-ticket-clarification.test.ts b/e2e/tier2/us05-unclear-ticket-clarification.test.ts index 735cd4d..27b451b 100644 --- a/e2e/tier2/us05-unclear-ticket-clarification.test.ts +++ b/e2e/tier2/us05-unclear-ticket-clarification.test.ts @@ -20,7 +20,7 @@ import { e2eEnv } from "../env.js"; * return STATUS: clarification_needed, post numbered questions as a Jira * comment, move the ticket to Backlog, and clean up Redis/sandbox. */ -describe("US-5: Unclear ticket triggers clarification", () => { +describe("US-05: Unclear ticket triggers clarification", () => { let ticketKey: string; let branchName: string; diff --git a/e2e/tier2/us06-clarification-answered.test.ts b/e2e/tier2/us06-clarification-answered.test.ts index ee4eed0..132b526 100644 --- a/e2e/tier2/us06-clarification-answered.test.ts +++ b/e2e/tier2/us06-clarification-answered.test.ts @@ -31,7 +31,7 @@ import { e2eEnv } from "../env.js"; * This covers two full workflow runs in sequence, so the per-test timeout * is larger than the project default. */ -describe("US-6: Clarification answered → ticket completes", () => { +describe("US-06: Clarification answered → ticket completes", () => { // Unique value so the PR content check can't pass on pre-existing files const uniqueGreeting = `Hello from Blazebot US-6 ${Date.now()}`; let ticketKey: string; diff --git a/e2e/tier2/us07-agent-failure-backlog.test.ts b/e2e/tier2/us07-agent-failure-backlog.test.ts index cee00f2..e66f46c 100644 --- a/e2e/tier2/us07-agent-failure-backlog.test.ts +++ b/e2e/tier2/us07-agent-failure-backlog.test.ts @@ -23,7 +23,7 @@ import { e2eEnv } from "../env.js"; * the wrapper script's cleanup still touches the sentinel, and * parseResearchStatus defaults to `failed` on empty/partial stdout. */ -describe("US-7: Agent failure moves ticket to Backlog", () => { +describe("US-07: Agent failure moves ticket to Backlog", () => { let ticketKey: string; let branchName: string; diff --git a/e2e/tier2/us08-previously-failed-skip.test.ts b/e2e/tier2/us08-previously-failed-skip.test.ts index 61d0ddc..58c2653 100644 --- a/e2e/tier2/us08-previously-failed-skip.test.ts +++ b/e2e/tier2/us08-previously-failed-skip.test.ts @@ -14,6 +14,7 @@ import { cleanupFailed, } from "../helpers/redis.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; @@ -27,8 +28,12 @@ import { e2eEnv } from "../env.js"; * We seed the failure marker directly because its only production trigger is * the workflow's catch-block safeguard (Jira unreachable during error * recovery), which is impractical to force in e2e. + * + * Both dispatch paths must honour the marker: the webhook path (fires when + * the ticket enters AI) and the cron re-poll path (fires on every tick + * while the ticket sits in AI). This test exercises both. */ -describe("US-8: Previously-failed ticket is skipped", () => { +describe("US-08: Previously-failed ticket is skipped", () => { let ticketKey: string; let branchName: string; @@ -62,10 +67,18 @@ describe("US-8: Previously-failed ticket is skipped", () => { // because the failure marker is present. await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - // 4. Give the webhook + dispatch precheck time to run, then assert that - // no active-run Redis entry was ever created. We poll for the full - // window rather than a single check to catch any claim that might - // appear mid-window (e.g. from a retry). + // 4. Explicitly poke the cron re-poll path — this is the scenario US-8 + // is primarily about. Cron discovers the ticket (still in AI), calls + // dispatchTicket, and the failed-marker precheck returns + // `previously_failed`. The response body confirms both auth and + // deployment-protection bypass are configured correctly. + const cronRes = await callCronPoll(); + expect(cronRes.status).toBe(200); + + // 5. Give both dispatch paths time to run, then assert that no active-run + // Redis entry was ever created. We poll for the full window rather + // than a single check to catch any claim that might appear mid-window + // (e.g. from a retry). const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const runId = await getRunId(ticketKey); diff --git a/e2e/tier2/us09-failed-marker-cleared.test.ts b/e2e/tier2/us09-failed-marker-cleared.test.ts index bcb92bd..01428ba 100644 --- a/e2e/tier2/us09-failed-marker-cleared.test.ts +++ b/e2e/tier2/us09-failed-marker-cleared.test.ts @@ -23,8 +23,14 @@ import { e2eEnv } from "../env.js"; * Reconcile (part of the cron poll) lists all failure markers and clears any * whose ticket is no longer in the AI column snapshot. After clearing, the * ticket can be retried on a future re-entry into AI. + * + * The Jira webhook does NOT clear failure markers on its own — it only + * cancels active runs. Reconcile is the sole clearer, so this test must + * poke `/cron/poll` explicitly. The helper attaches + * `x-vercel-protection-bypass: ${VERCEL_AUTOMATION_BYPASS_SECRET}` to + * bypass preview deployment protection (set this env var before running). */ -describe("US-9: Failed marker cleared when ticket leaves AI", () => { +describe("US-09: Failed marker cleared when ticket leaves AI", () => { let ticketKey: string; let branchName: string; @@ -59,7 +65,9 @@ describe("US-9: Failed marker cleared when ticket leaves AI", () => { expect(await isTicketFailed(ticketKey)).toBe(true); // 3. Trigger cron — runs reconcileRuns which clears markers for tickets - // not in the AI column snapshot + // not in the AI column snapshot. The helper sends the bypass header, + // so a successful 200 confirms both auth + deployment protection are + // configured correctly for this run. const res = await callCronPoll(); expect(res.status).toBe(200); diff --git a/e2e/tier2/us11-capacity-limit-respected.test.ts b/e2e/tier2/us11-capacity-limit-respected.test.ts new file mode 100644 index 0000000..75192d3 --- /dev/null +++ b/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + deleteTicket, +} from "../helpers/jira.js"; +import { findPR, closePR, deleteBranch } from "../helpers/github.js"; +import { + getRunId, + listAll as listAllRuns, + cleanup as redisCleanup, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-11: Capacity limit respected + * + * Capacity is measured against the Redis active-runs registry, not against + * `Sandbox.list()` — a dispatched ticket is immediately counted, so the + * (N+1)th ticket in a batch reliably sees `at_capacity` on both the webhook + * and cron paths. + * + * Flow: + * 1. Create MAX_CONCURRENT_AGENTS + 1 tickets in quick succession. + * 2. Move them all to AI. Each move fires a Jira webhook that calls + * dispatch; the first N claim Redis slots and start workflows, the + * (N+1)th hits the cap and is skipped. + * 3. Assert: exactly N claim entries exist in the registry for our + * ticket set, and the overflow ticket has no entry. + * + * Cleanup stops every sandbox and closes any PRs the N in-flight workflows + * managed to open before we interrupted them. + */ +describe("US-11: Capacity limit respected", () => { + const tickets: Array<{ ticketKey: string; branchName: string; prNumber?: number }> = []; + + afterAll(async () => { + for (const t of tickets) { + await stopSandboxesForTicket(t.ticketKey).catch(() => {}); + if (t.prNumber) await closePR(t.prNumber).catch(() => {}); + await deleteBranch(t.branchName).catch(() => {}); + await redisCleanup(t.ticketKey).catch(() => {}); + await deleteTicket(t.ticketKey).catch(() => {}); + } + }); + + it("admits exactly MAX_CONCURRENT_AGENTS when more tickets arrive at once", async () => { + const max = e2eEnv.MAX_CONCURRENT_AGENTS; + const total = max + 1; + + // 1. Create N+1 tickets in parallel (all land in Backlog by default) + const created = await Promise.all( + Array.from({ length: total }, (_, i) => + createTestTicket({ + summary: `[E2E] Capacity batch ${i + 1}/${total}`, + description: [ + "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", + "", + "Acceptance criteria:", + "- Route file at app/api/health/route.ts", + "- Exports a GET handler", + '- Returns JSON response: { status: "ok" }', + ].join("\n"), + }), + ), + ); + for (const { ticketKey } of created) { + tickets.push({ ticketKey, branchName: `blazebot/${ticketKey.toLowerCase()}` }); + } + + // 2. Move them all to AI in parallel. Jira fires a webhook per transition; + // each webhook triggers dispatch, which claims Redis via HSETNX. The + // registry-based capacity check rejects the overflow ticket. + await Promise.all( + tickets.map((t) => moveTicketToColumn(t.ticketKey, e2eEnv.COLUMN_AI)), + ); + + // 3. Wait for exactly `max` of our tickets to be claimed. We poll the + // registry rather than the per-ticket entry because the Jira webhook + // ordering is not guaranteed — any `max` of the `total` can win. + const ticketKeys = new Set(tickets.map((t) => t.ticketKey)); + const claimed = await waitFor( + async () => { + const all = await listAllRuns(); + const ours = all.filter((e) => ticketKeys.has(e.ticketKey)); + return ours.length === max ? ours : null; + }, + { + description: `${max} of ${total} tickets claimed (capacity limit)`, + timeoutMs: 60_000, + intervalMs: 2_000, + }, + ); + + expect(claimed.length).toBe(max); + const claimedKeys = new Set(claimed.map((e) => e.ticketKey)); + const loserKeys = tickets.map((t) => t.ticketKey).filter((k) => !claimedKeys.has(k)); + expect(loserKeys.length).toBe(1); + + // 4. Hold the window for a few seconds to catch any late racing claim + // (e.g. a retry that would push us over cap). Value stays at `max`. + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const all = await listAllRuns(); + const ours = all.filter((e) => ticketKeys.has(e.ticketKey)); + expect(ours.length).toBe(max); + await new Promise((r) => setTimeout(r, 2_000)); + } + + // 5. The losing ticket has no registry entry and no PR. + const [loserKey] = loserKeys; + expect(await getRunId(loserKey)).toBeNull(); + const loserBranch = `blazebot/${loserKey.toLowerCase()}`; + expect(await findPR(loserBranch)).toBeNull(); + + // 6. Best-effort: capture any PRs the winners managed to open so cleanup + // can close them. + for (const t of tickets) { + const pr = await findPR(t.branchName).catch(() => null); + if (pr) t.prNumber = pr.number; + } + }); +}); diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 5bf34e1..499a507 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -20,13 +20,7 @@ vi.mock("../workflows/agent.js", () => ({ agentWorkflow: "agentWorkflow_sentinel", })); -const mockSandboxList = vi.fn(); const mockStopTicketSandboxes = vi.fn(); -vi.mock("@vercel/sandbox", () => ({ - Sandbox: { - list: (...args: any[]) => mockSandboxList(...args), - }, -})); vi.mock("../sandbox/stop-ticket-sandboxes.js", () => ({ stopTicketSandboxes: (...args: any[]) => mockStopTicketSandboxes(...args), })); @@ -55,6 +49,7 @@ function makeAdapters( fetchTicket: ReturnType; findPR: ReturnType; isTicketFailed: ReturnType; + listAll: ReturnType; }> = {}, ): Adapters { let claimedValue: string | undefined; @@ -92,7 +87,7 @@ function makeAdapters( getRunId: overrides.getRunId ?? vi.fn().mockImplementation(async () => claimedValue), - listAll: vi.fn(), + listAll: overrides.listAll ?? vi.fn().mockResolvedValue([]), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), @@ -103,16 +98,9 @@ function makeAdapters( describe("dispatchTicket", () => { beforeEach(() => { - mockSandboxList.mockReset(); mockStart.mockReset(); mockGetRun.mockReset(); mockStopTicketSandboxes.mockReset(); - mockSandboxList.mockResolvedValue({ - json: { - sandboxes: [], - pagination: { count: 0, next: null, prev: null }, - }, - }); mockStart.mockResolvedValue({ runId: "run_123" }); mockStopTicketSandboxes.mockResolvedValue(0); }); @@ -181,18 +169,14 @@ describe("dispatchTicket", () => { expect(mockStart).not.toHaveBeenCalled(); }); - it("returns at_capacity when sandbox count >= max", async () => { - mockSandboxList.mockResolvedValue({ - json: { - sandboxes: [ - { status: "running" }, - { status: "running" }, - { status: "running" }, - ], - pagination: { count: 3, next: null, prev: null }, - }, + it("returns at_capacity when active run count >= max", async () => { + const adapters = makeAdapters({ + listAll: vi.fn().mockResolvedValue([ + { ticketKey: "PROJ-1", runId: "run_a" }, + { ticketKey: "PROJ-2", runId: "run_b" }, + { ticketKey: "PROJ-3", runId: "run_c" }, + ]), }); - const adapters = makeAdapters(); const { dispatchTicket } = await import("./dispatch.js"); const result = await dispatchTicket("PROJ-42", adapters, 3); @@ -202,44 +186,46 @@ describe("dispatchTicket", () => { expect(mockStart).not.toHaveBeenCalled(); }); - it("paginates sandbox list when counting active sandboxes", async () => { - mockSandboxList - .mockResolvedValueOnce({ - json: { - sandboxes: [{ status: "running" }], - pagination: { count: 1, next: 123, prev: null }, - }, - }) - .mockResolvedValueOnce({ - json: { - sandboxes: [{ status: "running" }, { status: "running" }], - pagination: { count: 2, next: null, prev: 123 }, - }, - }); - const adapters = makeAdapters(); + it("counts fresh claiming sentinels toward capacity", async () => { + const freshClaim = `claiming:${Date.now()}`; + const adapters = makeAdapters({ + listAll: vi.fn().mockResolvedValue([ + { ticketKey: "PROJ-1", runId: "run_a" }, + { ticketKey: "PROJ-2", runId: "run_b" }, + { ticketKey: "PROJ-3", runId: freshClaim }, + ]), + }); const { dispatchTicket } = await import("./dispatch.js"); const result = await dispatchTicket("PROJ-42", adapters, 3); expect(result).toEqual({ started: false, reason: "at_capacity" }); - expect(mockSandboxList).toHaveBeenCalledTimes(2); - expect(mockSandboxList.mock.calls[0][0]).toMatchObject({ - limit: 100, - since: undefined, - signal: expect.any(AbortSignal), - }); - expect(mockSandboxList.mock.calls[1][0]).toMatchObject({ - limit: 100, - since: 123, - signal: expect.any(AbortSignal), - }); expect(adapters.runRegistry.claim).not.toHaveBeenCalled(); - expect(mockStart).not.toHaveBeenCalled(); }); - it("fails closed when sandbox count check fails", async () => { - mockSandboxList.mockRejectedValue(new Error("sandbox list timeout")); - const adapters = makeAdapters(); + it("ignores stale claiming sentinels (older than STALE_CLAIM_MS)", async () => { + const { STALE_CLAIM_MS } = await import("./dispatch.js"); + const staleClaim = `claiming:${Date.now() - STALE_CLAIM_MS - 1_000}`; + const adapters = makeAdapters({ + listAll: vi.fn().mockResolvedValue([ + { ticketKey: "PROJ-1", runId: "run_a" }, + { ticketKey: "PROJ-2", runId: "run_b" }, + { ticketKey: "PROJ-3", runId: staleClaim }, + ]), + }); + const { dispatchTicket } = await import("./dispatch.js"); + + // Only 2 live entries (stale sentinel dropped) → under cap of 3. + const result = await dispatchTicket("PROJ-42", adapters, 3); + + expect(result.started).toBe(true); + expect(mockStart).toHaveBeenCalled(); + }); + + it("fails closed when the run registry is unreachable", async () => { + const adapters = makeAdapters({ + listAll: vi.fn().mockRejectedValue(new Error("registry unreachable")), + }); const { dispatchTicket } = await import("./dispatch.js"); const result = await dispatchTicket("PROJ-42", adapters, 5); @@ -343,16 +329,9 @@ describe("dispatchTicket", () => { describe("failed-ticket safeguard full loop", () => { beforeEach(() => { - mockSandboxList.mockReset(); mockStart.mockReset(); mockGetRun.mockReset(); mockStopTicketSandboxes.mockReset(); - mockSandboxList.mockResolvedValue({ - json: { - sandboxes: [], - pagination: { count: 0, next: null, prev: null }, - }, - }); mockStart.mockResolvedValue({ runId: "run_123" }); mockStopTicketSandboxes.mockResolvedValue(0); }); diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index 8de94e2..0a984aa 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -1,5 +1,4 @@ import { start, getRun } from "workflow/api"; -import { Sandbox } from "@vercel/sandbox"; import { env } from "../../env.js"; import { agentWorkflow } from "../workflows/agent.js"; import { logger } from "./logger.js"; @@ -7,9 +6,13 @@ import type { Adapters } from "./adapters.js"; import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; const CLAIMING_PREFIX = "claiming:"; -const SANDBOX_LIST_TIMEOUT_MS = 1_000; -const SANDBOX_LIST_PAGE_LIMIT = 100; -const SANDBOX_COUNT_FAILED = Number.MAX_SAFE_INTEGER; +/** + * Stale-claim horizon — claiming sentinels older than this are ignored + * when counting active runs for capacity. Matches the reconcile threshold + * (src/lib/reconcile.ts) so a crashed dispatch can't deadlock capacity + * for longer than reconcile would take to sweep it anyway. + */ +export const STALE_CLAIM_MS = 5 * 60 * 1000; export function isClaimingSentinel(runId: string): boolean { return runId.startsWith(CLAIMING_PREFIX); @@ -31,15 +34,10 @@ export interface DispatchResult { | "wrong_project_key"; } -export interface DispatchOptions { - skipCapacityCheck?: boolean; -} - export async function dispatchTicket( ticketKey: string, adapters: Adapters, maxConcurrentAgents: number, - options: DispatchOptions = {}, ): Promise { const expectedProjectKey = env.JIRA_PROJECT_KEY.trim().toUpperCase(); const expectedAiStatus = env.COLUMN_AI.trim().toLowerCase(); @@ -55,13 +53,9 @@ export async function dispatchTicket( return { started: false, reason: "previously_failed" }; } - if (!options.skipCapacityCheck) { - stage = "precheck_capacity"; - if (await isAtCapacity(maxConcurrentAgents)) { - return { started: false, reason: "at_capacity" }; - } - } else { - logger.info({ ticketKey }, "dispatch_capacity_check_skipped"); + stage = "precheck_capacity"; + if (await isAtCapacity(maxConcurrentAgents, runRegistry)) { + return { started: false, reason: "at_capacity" }; } stage = "claim_ticket"; @@ -133,47 +127,45 @@ export async function dispatchTicket( } } -async function isAtCapacity(max: number): Promise { - const active = await getActiveSandboxCount(); - if (active === SANDBOX_COUNT_FAILED) { - logger.warn({ max }, "dispatch_capacity_check_failed_closed"); +/** + * Capacity check counts active runs in the Redis registry — this is the + * per-app concurrency limit for blazebot, not a per-team sandbox quota. + * + * We deliberately exclude claiming sentinels older than STALE_CLAIM_MS so + * a crashed dispatch can't deadlock capacity indefinitely; reconcile will + * sweep those stale entries on its next run, but the capacity check + * shouldn't wait for it. + * + * Fails closed on registry errors — better to stall new dispatches than + * to over-allocate if we can't see the current state. + */ +async function isAtCapacity( + max: number, + runRegistry: Adapters["runRegistry"], +): Promise { + let entries: Awaited>; + try { + entries = await runRegistry.listAll(); + } catch (err) { + logger.warn( + { max, error: (err as Error).message }, + "dispatch_capacity_check_failed_closed", + ); return true; } + + const now = Date.now(); + const active = entries.filter(({ runId }) => { + if (!isClaimingSentinel(runId)) return true; + return now - getClaimTimestamp(runId) <= STALE_CLAIM_MS; + }).length; + if (active < max) return false; logger.info({ active, max }, "dispatch_at_capacity"); return true; } -async function getActiveSandboxCount(): Promise { - try { - let runningCount = 0; - let since: number | undefined; - - while (true) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), SANDBOX_LIST_TIMEOUT_MS); - try { - const { json } = await Sandbox.list({ - limit: SANDBOX_LIST_PAGE_LIMIT, - since, - signal: controller.signal, - }); - runningCount += json.sandboxes.filter( - (sandbox: { status?: string }) => sandbox.status === "running", - ).length; - if (json.pagination.next == null) return runningCount; - since = json.pagination.next; - } finally { - clearTimeout(timeout); - } - } - } catch (err) { - logger.warn({ error: (err as Error).message }, "sandbox_count_check_failed"); - return SANDBOX_COUNT_FAILED; - } -} - async function verifyClaimNotCancelled( ticketKey: string, expectedClaimValue: string, diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index d8fd141..7b8f858 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -86,9 +86,7 @@ export default defineEventHandler(async (event) => { }, "webhook_dispatch_started", ); - const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS, { - skipCapacityCheck: true, - }); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); logger.info( { ticketKey, @@ -137,9 +135,7 @@ export default defineEventHandler(async (event) => { }, "webhook_dispatch_started", ); - const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS, { - skipCapacityCheck: true, - }); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); logger.info( { ticketKey, started: result.started, reason: result.reason, runId: result.runId }, From cc3bba495c84ccca75e1158f244de9dffd56454a Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 11:56:45 +0200 Subject: [PATCH 42/71] fix: at capacity error --- .../us11-capacity-limit-respected.test.ts | 20 +++++ e2e/vitest.e2e.config.ts | 5 ++ src/lib/dispatch.test.ts | 80 +++++++++++++++++++ src/lib/dispatch.ts | 40 ++++++++++ 4 files changed, 145 insertions(+) diff --git a/e2e/tier2/us11-capacity-limit-respected.test.ts b/e2e/tier2/us11-capacity-limit-respected.test.ts index 75192d3..c5057b5 100644 --- a/e2e/tier2/us11-capacity-limit-respected.test.ts +++ b/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { createTestTicket, moveTicketToColumn, + getTicketStatus, deleteTicket, } from "../helpers/jira.js"; import { findPR, closePR, deleteBranch } from "../helpers/github.js"; @@ -37,6 +38,25 @@ describe("US-11: Capacity limit respected", () => { const tickets: Array<{ ticketKey: string; branchName: string; prNumber?: number }> = []; afterAll(async () => { + // Cancel running workflows FIRST by moving tickets out of AI. The Jira + // webhook then sees "left AI" and calls cancelTrackedRun, which + // gracefully stops the workflow before any moveTicket step fires a + // 404 on a deleted Jira issue. + await Promise.all( + tickets.map(async (t) => { + try { + const status = await getTicketStatus(t.ticketKey); + if (status.toLowerCase() === e2eEnv.COLUMN_AI.toLowerCase()) { + await moveTicketToColumn(t.ticketKey, e2eEnv.COLUMN_BACKLOG); + } + } catch {} + }), + ); + + // Give the webhook-driven cancel path a moment to propagate before we + // start tearing down sandboxes and tickets out from under it. + await new Promise((r) => setTimeout(r, 5_000)); + for (const t of tickets) { await stopSandboxesForTicket(t.ticketKey).catch(() => {}); if (t.prNumber) await closePR(t.prNumber).catch(() => {}); diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 93b9cff..921a77f 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ name: "tier1", include: ["e2e/tier1/**/*.test.ts"], testTimeout: 120_000, + hookTimeout: 120_000, }, }, { @@ -39,6 +40,10 @@ export default defineConfig({ name: "tier2", include: ["e2e/tier2/**/*.test.ts"], testTimeout: 2_100_000, + // afterAll often iterates Sandbox.list() + runs commands inside + // each matching sandbox to confirm branch, which easily exceeds + // the 10s default. Mirror testTimeout so cleanup doesn't abort. + hookTimeout: 2_100_000, }, }, ], diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 499a507..640450f 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -235,6 +235,86 @@ describe("dispatchTicket", () => { expect(mockStart).not.toHaveBeenCalled(); }); + it("post-claim verify: latest-timestamp racer bails when cap overshot", async () => { + // Cap = 3. Two claims already exist (T1, T2). Three more dispatches + // race through concurrently (T3, T4, T5). All three pass the precheck + // (they each see 2 entries < 3) and all three claim. After all three + // claims land, Redis has 5 entries for cap=3 — the latest two + // timestamps must bail. We play the role of T5 and must bail. + const T1 = 10_000, T2 = 10_010, T3 = 10_020, T4 = 10_030, T5 = 10_040; + const snapshots = [ + // Call #1 — precheck: 2 pre-existing entries (< 3, passes) + [ + { ticketKey: "PROJ-1", runId: `claiming:${T1}` }, + { ticketKey: "PROJ-2", runId: `claiming:${T2}` }, + ], + // Call #2 — post-claim: our claim landed, plus two other racers + // that also slipped through the precheck window + [ + { ticketKey: "PROJ-1", runId: `claiming:${T1}` }, + { ticketKey: "PROJ-2", runId: `claiming:${T2}` }, + { ticketKey: "PROJ-3", runId: `claiming:${T3}` }, + { ticketKey: "PROJ-4", runId: `claiming:${T4}` }, + { ticketKey: "PROJ-LATE", runId: `claiming:${T5}` }, + ], + ]; + let call = 0; + const listAll = vi.fn().mockImplementation(async () => snapshots[call++] ?? []); + const unregister = vi.fn().mockResolvedValue(undefined); + + const realNow = Date.now; + Date.now = () => T5; + + try { + const adapters = makeAdapters({ listAll, unregister }); + const { dispatchTicket } = await import("./dispatch.js"); + const result = await dispatchTicket("PROJ-LATE", adapters, 3); + expect(result).toEqual({ started: false, reason: "at_capacity" }); + expect(unregister).toHaveBeenCalledWith("PROJ-LATE"); + expect(mockStart).not.toHaveBeenCalled(); + } finally { + Date.now = realNow; + } + }); + + it("post-claim verify: earlier-timestamp racer wins even when cap overshot", async () => { + // Cap = 3. Same race, but our claim is the earliest of the three + // racers — we should be one of the three retained. + const T1 = 10_000, T2 = 10_010, T3 = 10_020, T4 = 10_030, T5 = 10_040; + const snapshots = [ + // Precheck: 2 entries + [ + { ticketKey: "PROJ-1", runId: `claiming:${T1}` }, + { ticketKey: "PROJ-2", runId: `claiming:${T2}` }, + ], + // Post-claim: 5 entries, ours at T3 (earliest of the three racers) + [ + { ticketKey: "PROJ-1", runId: `claiming:${T1}` }, + { ticketKey: "PROJ-2", runId: `claiming:${T2}` }, + { ticketKey: "PROJ-EARLY", runId: `claiming:${T3}` }, + { ticketKey: "PROJ-4", runId: `claiming:${T4}` }, + { ticketKey: "PROJ-5", runId: `claiming:${T5}` }, + ], + ]; + let call = 0; + const listAll = vi.fn().mockImplementation(async () => snapshots[call++] ?? []); + const unregister = vi.fn().mockResolvedValue(undefined); + + const realNow = Date.now; + Date.now = () => T3; + + try { + const adapters = makeAdapters({ listAll, unregister }); + const { dispatchTicket } = await import("./dispatch.js"); + const result = await dispatchTicket("PROJ-EARLY", adapters, 3); + expect(result.started).toBe(true); + expect(mockStart).toHaveBeenCalled(); + expect(unregister).not.toHaveBeenCalledWith("PROJ-EARLY"); + } finally { + Date.now = realNow; + } + }); + it("aborts workflow if claim was removed during dispatch", async () => { const mockCancel = vi.fn().mockResolvedValue(undefined); mockGetRun.mockReturnValue({ cancel: mockCancel }); diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index 0a984aa..085de2f 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -67,6 +67,46 @@ export async function dispatchTicket( } claimHeld = true; + // Post-claim capacity verify. The precheck above is not atomic with + // claim(), so N concurrent dispatches for *different* tickets can all + // pass the precheck and then all claim successfully — pushing Redis + // over the cap. Re-read the registry with our own claim visible and + // decide fairly who stays. + // + // Fairness rule: sort by (claim timestamp ascending, ticketKey + // ascending as tie-breaker); the first `max` entries win. Existing + // non-sentinel entries (already-running workflows) are treated as + // timestamp 0 so they always win over new claims. Every racer + // eventually converges on the same ordering once Redis writes are + // visible to all, so exactly the excess bail. + stage = "postclaim_capacity"; + const racers = await runRegistry.listAll(); + const now = Date.now(); + const liveRacers = racers.filter(({ runId }) => { + if (!isClaimingSentinel(runId)) return true; + return now - getClaimTimestamp(runId) <= STALE_CLAIM_MS; + }); + if (liveRacers.length > maxConcurrentAgents) { + const sorted = [...liveRacers].sort((a, b) => { + const ta = isClaimingSentinel(a.runId) ? getClaimTimestamp(a.runId) : 0; + const tb = isClaimingSentinel(b.runId) ? getClaimTimestamp(b.runId) : 0; + if (ta !== tb) return ta - tb; + return a.ticketKey.localeCompare(b.ticketKey); + }); + const winners = new Set( + sorted.slice(0, maxConcurrentAgents).map((e) => e.ticketKey), + ); + if (!winners.has(ticketKey)) { + await runRegistry.unregister(ticketKey).catch(() => {}); + claimHeld = false; + logger.info( + { ticketKey, liveRacers: liveRacers.length, max: maxConcurrentAgents }, + "dispatch_at_capacity_post_claim", + ); + return { started: false, reason: "at_capacity" }; + } + } + stage = "fetch_ticket"; const ticket = await issueTracker.fetchTicket(ticketKey); const ticketStatus = ticket.trackerStatus.trim().toLowerCase(); From a69c9f636a6ddf7bc3a65a6aab9588cddd2568f6 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 12:06:36 +0200 Subject: [PATCH 43/71] fix: us13 cleanup --- .../us13-webhook-immediate-dispatch.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/e2e/tier2/us13-webhook-immediate-dispatch.test.ts b/e2e/tier2/us13-webhook-immediate-dispatch.test.ts index 7beaf24..6170419 100644 --- a/e2e/tier2/us13-webhook-immediate-dispatch.test.ts +++ b/e2e/tier2/us13-webhook-immediate-dispatch.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { createTestTicket, moveTicketToColumn, + getTicketStatus, deleteTicket, } from "../helpers/jira.js"; import { findPR, closePR, deleteBranch } from "../helpers/github.js"; @@ -28,7 +29,21 @@ describe("US-13: Webhook-triggered immediate dispatch", () => { let prNumber: number | undefined; afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + // The test returns as soon as the claim appears — the workflow is still + // running. Cancel it by moving the ticket out of AI: the Jira webhook + // then calls cancelTrackedRun, which stops the workflow gracefully + // before its moveTicket step can 404 on a deleted issue. + if (ticketKey) { + try { + const status = await getTicketStatus(ticketKey); + if (status.toLowerCase() === e2eEnv.COLUMN_AI.toLowerCase()) { + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); + } + } catch {} + // Settling window for webhook-cancel → run.cancel → sandbox teardown. + await new Promise((r) => setTimeout(r, 5_000)); + await stopSandboxesForTicket(ticketKey).catch(() => {}); + } if (prNumber) await closePR(prNumber).catch(() => {}); if (branchName) await deleteBranch(branchName).catch(() => {}); if (ticketKey) { From 8ad8bcd0256388331bf2f013b5a75fd59773249f Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 12:32:07 +0200 Subject: [PATCH 44/71] fix: stale run --- docs/user-stories.md | 6 +- e2e/tier2/us14-stale-claim-cleanup.test.ts | 97 +++++++++++++++++++ e2e/tier2/us15-orphaned-run-cancelled.test.ts | 91 +++++++++++++++++ src/lib/reconcile.test.ts | 18 +++- src/lib/reconcile.ts | 11 +++ src/routes/webhooks/jira.post.ts | 6 ++ 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 e2e/tier2/us14-stale-claim-cleanup.test.ts create mode 100644 e2e/tier2/us15-orphaned-run-cancelled.test.ts diff --git a/docs/user-stories.md b/docs/user-stories.md index 048d2dd..0b4e573 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -346,11 +346,13 @@ T=5min: Reconciliation runs **Expected behavior:** 1. Reconciliation finds claim older than 5 minutes -2. Stale claim removed from Redis -3. Ticket can be picked up by next poll cycle +2. Any sandbox matching the ticket branch is stopped (covers the case where dispatch crashed between `start()` and `register()`, leaving a sentinel in Redis but a live sandbox) +3. Stale claim removed from Redis +4. Ticket can be picked up by next poll cycle **Verifications:** - Redis entry removed +- No sandbox running for this ticket - Next dispatch for same ticket succeeds --- diff --git a/e2e/tier2/us14-stale-claim-cleanup.test.ts b/e2e/tier2/us14-stale-claim-cleanup.test.ts new file mode 100644 index 0000000..dd7ab5d --- /dev/null +++ b/e2e/tier2/us14-stale-claim-cleanup.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { + getRunId, + setEntry, + cleanup as redisCleanup, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-14: Stale claim cleaned up + * + * If a dispatch process crashes after calling `claim()` but before the + * workflow actually starts, the `claiming:` sentinel is left behind in + * Redis. Because `claim()` uses HSETNX, that stale entry would block every + * future dispatch for the same ticket. Reconcile's job is to sweep any + * sentinel older than STALE_CLAIM_MS (5 min) so the ticket becomes + * dispatchable again on the next poll. + * + * We seed the stale sentinel directly — forcing a real dispatch crash mid- + * claim at the e2e layer is impractical, and the reconcile path doesn't care + * how the sentinel got there. The ticket stays in Backlog throughout: the + * cleanup rule fires on claim age alone, regardless of column. + */ +describe("US-14: Stale claim cleaned up", () => { + let ticketKey: string; + + afterAll(async () => { + // Defensive sandbox sweep: the stale-claim scenario assumes dispatch + // crashed "before starting a workflow" (user story wording), so no + // sandbox should exist. Production reconcile also does NOT stop + // sandboxes on the stale-sentinel path (reconcileInflightClaim only + // unregisters). If something does slip through — e.g. a crash between + // `start()` and `register()` in dispatch — only this cleanup will catch + // it, since nothing else in the test does. + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("removes a claiming sentinel older than 5 minutes on reconcile", async () => { + // 1. Create a ticket and pin it to Backlog. Not strictly required for + // the stale-claim rule (cleanup fires on age alone), but pinning + // away from AI prevents a just-in-time dispatch from racing us: if + // the project's default column ever becomes AI, the webhook would + // otherwise fire on create and try to dispatch. + const ticket = await createTestTicket({ + summary: "[E2E] Stale claim cleanup", + description: "Seeded stale claim; reconcile should clean it up.", + }); + ticketKey = ticket.ticketKey; + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + + // 2. Seed a `claiming:` sentinel timestamped 6 minutes ago — safely + // past the 5-minute STALE_CLAIM_MS threshold in src/lib/reconcile.ts. + const staleTimestamp = Date.now() - 6 * 60 * 1000; + const staleClaim = `claiming:${staleTimestamp}`; + await setEntry(ticketKey, staleClaim); + expect(await getRunId(ticketKey)).toBe(staleClaim); + + // 3. Trigger the cron — reconcileRuns is invoked after dispatch and + // iterates active runs; our stale sentinel matches the age check. + const res = await callCronPoll(); + expect(res.status).toBe(200); + + // 4. Redis entry is gone. Once unregistered, HSETNX will succeed on + // the next dispatch — the "next dispatch succeeds" verification in + // the user story follows directly from the entry being absent, and + // HSETNX semantics are covered by US-10 and the dispatch unit tests. + await waitFor( + async () => ((await getRunId(ticketKey)) === null ? true : null), + { + description: `stale claim cleared for ${ticketKey}`, + timeoutMs: 30_000, + intervalMs: 2_000, + }, + ); + + // 5. No sandbox matching this ticket. The user story doesn't list this + // explicitly (the scenario assumes "crashed before starting a + // workflow"), but we assert it anyway so a regression where the + // stale-claim path leaks sandboxes surfaces here — see note in + // afterAll about the production gap. + expect(await stopSandboxesForTicket(ticketKey)).toBe(0); + }); +}); diff --git a/e2e/tier2/us15-orphaned-run-cancelled.test.ts b/e2e/tier2/us15-orphaned-run-cancelled.test.ts new file mode 100644 index 0000000..87f9451 --- /dev/null +++ b/e2e/tier2/us15-orphaned-run-cancelled.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "../helpers/jira.js"; +import { + getRunId, + setEntry, + cleanup as redisCleanup, +} from "../helpers/redis.js"; +import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; +import { waitFor } from "../helpers/wait.js"; +import { e2eEnv } from "../env.js"; + +/** + * US-15: Orphaned run cancelled when ticket leaves AI + * + * Reconcile is the backup path for cleaning up runs whose ticket has left + * the AI column. The Jira webhook normally cancels runs synchronously on + * transition (see webhooks/jira.post.ts → cancelTrackedRun), but that path + * can be missed if the webhook is disabled, misconfigured, or fails to + * deliver. Reconcile catches those: it compares the AI-column snapshot + * against the active-run registry, re-verifies each suspect ticket against + * Jira (guards against poll lag / JQL index staleness), and calls cancelRun + * for any confirmed orphan. + * + * We seed a non-sentinel runId on a Backlog ticket to isolate the reconcile + * path without involving a real workflow. `cancelRun` swallows the + * `getRun(…).cancel()` error when the runId doesn't exist but still + * unregisters the Redis entry and stops any matching sandboxes — so a + * cleared Redis entry after the cron call proves reconcile reached the + * cancel path (it's the only site that unregisters in this state). + */ +describe("US-15: Orphaned run cancelled when ticket leaves AI", () => { + const SEEDED_RUN_ID = "run_e2e_us15_orphan"; + let ticketKey: string; + + afterAll(async () => { + if (ticketKey) { + await stopSandboxesForTicket(ticketKey).catch(() => {}); + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("cancels the run and clears the registry when the ticket is not in AI", async () => { + // 1. Create a ticket and pin it outside AI. Backlog is the conventional + // target for our fixtures; any non-AI column triggers the same + // reconcile branch. + const ticket = await createTestTicket({ + summary: "[E2E] Orphaned run reconcile", + description: "Seeded active run; ticket not in AI; reconcile should cancel.", + }); + ticketKey = ticket.ticketKey; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + + // 2. Seed a non-sentinel runId (a claiming: would trip the inflight + // branch instead). This simulates the "workflow registered" state + // where the webhook-based cancel was missed. + await setEntry(ticketKey, SEEDED_RUN_ID); + expect(await getRunId(ticketKey)).toBe(SEEDED_RUN_ID); + + // 3. Trigger the cron. Reconcile walks the registry, sees our ticket is + // absent from the AI-column snapshot, re-fetches from Jira (confirms + // Backlog), and calls cancelRun → unregister. + const res = await callCronPoll(); + expect(res.status).toBe(200); + + // 4. Redis entry cleared — evidence that reconcile reached cancelRun. + await waitFor( + async () => ((await getRunId(ticketKey)) === null ? true : null), + { + description: `orphaned run cleared for ${ticketKey}`, + timeoutMs: 30_000, + intervalMs: 2_000, + }, + ); + + // 5. No sandbox still running for this ticket. + expect(await stopSandboxesForTicket(ticketKey)).toBe(0); + + // 6. Ticket stays in Backlog — reconcile never moves tickets, it only + // cancels and cleans up state. + expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); + }); +}); diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index 6980645..2a69697 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -22,6 +22,11 @@ vi.mock("./cancel-run.js", () => ({ cancelRun: (...args: any[]) => mockCancelRun(...args), })); +const mockStopTicketSandboxes = vi.fn(); +vi.mock("../sandbox/stop-ticket-sandboxes.js", () => ({ + stopTicketSandboxes: (...args: any[]) => mockStopTicketSandboxes(...args), +})); + function makeRegistry( runs: Array<{ ticketKey: string; runId: string }> = [], failed: Array<{ ticketKey: string; meta: { runId: string; error: string; failedAt: string } }> = [], @@ -52,7 +57,10 @@ function makeIssueTracker( } describe("reconcileRuns", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockStopTicketSandboxes.mockResolvedValue(0); + }); it("skips fresh claiming entries", async () => { const registry = makeRegistry([ @@ -68,7 +76,7 @@ describe("reconcileRuns", () => { expect(mockCancelRun).not.toHaveBeenCalled(); }); - it("cleans stale claiming entries", async () => { + it("cleans stale claiming entries and stops sandboxes", async () => { const tenMinAgo = Date.now() - 10 * 60 * 1000; const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: `claiming:${tenMinAgo}` }, @@ -79,10 +87,13 @@ describe("reconcileRuns", () => { expect(result).toEqual({ cancelled: 0, cleaned: 1 }); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); + // A crash between dispatch.start() and dispatch.register() can leave + // a live sandbox shadowed by a sentinel; reconcile must sweep it. + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); }); - it("cancels fresh claiming entries for tickets that left AI column", async () => { + it("cancels fresh claiming entries for tickets that left AI column and stops sandboxes", async () => { const registry = makeRegistry([ { ticketKey: "PROJ-1", runId: `claiming:${Date.now()}` }, ]); @@ -94,6 +105,7 @@ describe("reconcileRuns", () => { expect(result).toEqual({ cancelled: 1, cleaned: 0 }); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); expect(mockCancelRun).not.toHaveBeenCalled(); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); }); it("keeps fresh claiming entry when missing from JQL snapshot but Jira still says AI", async () => { diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index 9638bde..41cd568 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -3,6 +3,7 @@ import { env } from "../../env.js"; import { isClaimingSentinel, getClaimTimestamp } from "./dispatch.js"; import { cancelRun } from "./cancel-run.js"; import { logger } from "./logger.js"; +import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; import { IssueTrackerNotFoundError, type IssueTrackerAdapter, @@ -148,6 +149,12 @@ async function reconcileInflightClaim( const ticketLeftAiColumn = !aiColumnTickets.has(ticketKey); if (claimIsStale) { + // Dispatch starts the workflow (which can spin up a sandbox in the + // research phase) *before* overwriting the sentinel with the real + // runId. A crash in that narrow window leaves a sentinel in Redis + // alongside a running sandbox we have no way to cancel via the + // workflow handle. Sweep any matching sandbox by branch name. + await stopTicketSandboxes(ticketKey).catch(() => {}); await runRegistry.unregister(ticketKey); logger.warn({ ticketKey, runId }, "reconcile_cleaned_stale_claim"); return { cancelled: 0, cleaned: 1 }; @@ -156,6 +163,10 @@ async function reconcileInflightClaim( if (ticketLeftAiColumn) { const leftAiColumn = await verifyTicketLeftAiColumn(ticketKey, issueTracker); if (!leftAiColumn) return { cancelled: 0, cleaned: 0 }; + // Same rationale as the stale-claim branch: a sentinel can shadow a + // real sandbox if dispatch crashed mid-start. Stop by branch before + // unregistering so the cancellation is complete. + await stopTicketSandboxes(ticketKey).catch(() => {}); await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId }, "reconcile_cancelled_inflight_claim"); await notifyTicketCancelled(ticketKey, "inflight_claim", onTicketCancelled); diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 7b8f858..9036170 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -6,6 +6,7 @@ import { createAdapters } from "../../lib/adapters.js"; import { cancelRun } from "../../lib/cancel-run.js"; import { dispatchTicket, isClaimingSentinel } from "../../lib/dispatch.js"; import { logger } from "../../lib/logger.js"; +import { stopTicketSandboxes } from "../../sandbox/stop-ticket-sandboxes.js"; /** * Jira webhook handler — triggers the same dispatch logic as the cron poller. @@ -224,6 +225,11 @@ async function cancelTrackedRun( if (!trackedRunId) return false; if (isClaimingSentinel(trackedRunId)) { + // Sentinel can shadow a real sandbox if dispatch already called + // start() but crashed before register(). Same gap that reconcile's + // stale-claim sweep covers — we catch it here on the faster webhook + // path so operators don't have to wait 5 minutes for reconcile. + await stopTicketSandboxes(ticketKey).catch(() => {}); await runRegistry.unregister(ticketKey).catch(() => {}); return true; } From d170ffbe9525166d9f2f44e30629ff478f247ce4 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 13:25:00 +0200 Subject: [PATCH 45/71] fix: us03 prompt --- docs/user-stories.md | 10 ++++--- e2e/tier2/us03-review-fix-cycle.test.ts | 36 +++++++++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 0b4e573..b6ed8ae 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -76,7 +76,10 @@ Attachments: **Example:** ``` Initial PR: Adds GET /api/ping returning { ping: "pong" } -Review comment: "Rename /ping to /healthcheck. Remove the old /ping route entirely." +Review comment: "Rename the endpoint from /api/ping to /api/healthcheck. + Move app/api/ping/route.ts to app/api/healthcheck/route.ts + and return { healthcheck: 'passed' }. The old /api/ping + route must no longer exist." ``` **Expected behavior:** @@ -84,14 +87,15 @@ Review comment: "Rename /ping to /healthcheck. Remove the old /ping route entire 2. Ticket discovered (via cron poll or webhook); agent detects existing PR on branch 3. Agent does NOT reset the branch (preserves existing work) 4. Research phase reads PR comments + check results -5. Implementation phase applies the requested changes +5. Implementation phase renames the route file and updates the response body 6. Push updates to same branch; no new PR created 7. Ticket moves back to "AI Review" **Verifications:** - Same PR number, no duplicate PR - PR has more commits than before the review fix -- Old `/ping` route removed, `/healthcheck` exists +- `app/api/healthcheck/route.ts` exists with the new response body +- `app/api/ping/route.ts` no longer exists on the branch - Ticket status = "AI Review" - Redis cleaned up - No sandbox running for this ticket diff --git a/e2e/tier2/us03-review-fix-cycle.test.ts b/e2e/tier2/us03-review-fix-cycle.test.ts index 07d1127..a47b5fb 100644 --- a/e2e/tier2/us03-review-fix-cycle.test.ts +++ b/e2e/tier2/us03-review-fix-cycle.test.ts @@ -93,15 +93,20 @@ describe("US-03: Review feedback triggers a fix cycle", () => { const commitsBefore = await getPRCommits(prNumber); const commitCountBefore = commitsBefore.length; - // Add a review comment requesting a rename with specific instructions + // Review feedback: explicit rename instruction. Previous iterations used + // "delete + create" wording which the agent sometimes interpreted as + // "edit in place at the original path" — phrasing as a rename makes the + // destination path unambiguous. await addPRComment( prNumber, [ - "Please make these changes:", - "1. Delete app/api/ping/route.ts entirely", - "2. Create app/api/healthcheck/route.ts instead", - '3. The new route must export a GET handler that returns JSON { healthcheck: "passed" }', - "4. No other files should be created or modified", + "Please rename this endpoint from /api/ping to /api/healthcheck.", + "", + "Concretely:", + "- Move the route file from app/api/ping/route.ts to app/api/healthcheck/route.ts", + '- Update the GET handler to return JSON { healthcheck: "passed" }', + "- The old /api/ping route must no longer exist after this change", + "- No other files should be created or modified", ].join("\n"), ); @@ -135,13 +140,7 @@ describe("US-03: Review feedback triggers a fix cycle", () => { expect(currentPR).not.toBeNull(); expect(currentPR!.number).toBe(prNumber); - // Old /ping route removed, /healthcheck exists (check PR aggregate diff) - const prFiles = await getPRFiles(prNumber); - const filenames = prFiles.map((f) => f.filename); - expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); - expect(filenames.some((f) => f.includes("/ping/"))).toBe(false); - - // Healthcheck route file exists on the branch with correct content + // Healthcheck route file exists on the branch with the new response body. const routeContent = await getFileContent( branchName, "app/api/healthcheck/route.ts", @@ -150,10 +149,19 @@ describe("US-03: Review feedback triggers a fix cycle", () => { expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); expect(routeContent).toContain('"passed"'); - // Old ping route must not exist on the branch + // Old ping route must be gone — the review asked to rename, not to + // leave a stale endpoint behind. const oldRoute = await getFileContent(branchName, "app/api/ping/route.ts"); expect(oldRoute).toBeNull(); + // PR diff reflects the rename on both sides. GitHub reports renames + // either as a single "renamed" entry with filename=new path, or as a + // remove+add pair — either way the new path appears in the list, and + // the old path does not appear as a surviving file. + const prFiles = await getPRFiles(prNumber); + const filenames = prFiles.map((f) => f.filename); + expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); + // Redis cleaned up await waitFor( async () => { From 7a08fd1e7a929754ff9f41c54d326abc0a0a26ca Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 13:35:02 +0200 Subject: [PATCH 46/71] fix: follow pr comments --- src/lib/prompts.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index a706613..1a13c49 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -26,7 +26,7 @@ You have access to **superpowers skills** installed globally. Use them. 1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. 2. Explore the repository structure. Read \`CLAUDE.md\`, \`AGENTS.md\` if present. 3. Check \`git log\` and \`git diff\` against the base branch to identify what's already been done on this branch. -4. If PR review feedback or CI/CD failures are included above, understand what needs to be fixed. +4. If PR review feedback or CI/CD failures are included above, understand what needs to be fixed. **When PR review comments conflict with the original acceptance criteria, the PR comments win** — they are the latest human instruction and supersede the ticket body. Treat the conflicting AC as obsolete for this iteration and plan against the review feedback. Do NOT return \`clarification_needed\` for this kind of conflict. 5. Identify what's already implemented vs. what remains. 6. Analyze relevant files, code patterns, test setup. 7. **Use the \`brainstorming\` skill** to think through the approach. @@ -120,6 +120,7 @@ You have access to **superpowers skills** installed globally. Use them. ## Constraints - Follow the plan — do not explore or re-research (already done). +- If the plan diverges from the original ticket acceptance criteria because it reflects PR review feedback, trust the plan. PR review comments supersede the original AC, and the research agent has already reconciled the two. Do not second-guess the plan by reverting to the ticket body. - Do not refactor code outside the scope of the plan. - Do not install new dependencies unless the plan specifies them. - Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). @@ -190,7 +191,7 @@ You have access to **superpowers skills** installed globally. Use them. ## Review Criteria - Does the implementation match the plan? -- Does it satisfy the acceptance criteria? +- Does it satisfy the acceptance criteria, **as amended by any PR review feedback**? When PR review comments conflict with the original ticket acceptance criteria, the comments win — they are the latest human instruction. Do not flag the implementation as failing AC just because it now diverges from the original ticket body. - Are there test gaps? - Are there obvious bugs or edge cases? - Does the code follow existing conventions? From 8716353d5a8ca6019a85cdfec1a49835744437c5 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 13:50:13 +0200 Subject: [PATCH 47/71] fix: us7 and us8 --- e2e/helpers/jira.ts | 22 +++++++++++++++++++ e2e/helpers/sandbox.ts | 21 ++++++++++++++++-- e2e/tier2/us08-previously-failed-skip.test.ts | 16 ++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index 5670e0e..0d6bbcd 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -88,6 +88,28 @@ export async function getTicketStatus(ticketKey: string): Promise { return data.fields.status.name; } +/** + * Ask Jira's search API whether `ticketKey` is currently visible under the + * given status. Unlike `/issue/{key}` (which returns committed state), the + * search endpoint hits an index that lags transitions by seconds or more. + * + * Cron's reconcile uses the same JQL-backed index to decide which tickets + * are in the AI column; tests that move a ticket and then immediately poke + * cron will race this lag and see a stale snapshot. Use this helper as a + * barrier between `moveTicketToColumn` and `callCronPoll`. + */ +export async function isTicketVisibleInJql( + ticketKey: string, + status: string, +): Promise { + const jql = `project = "${e2eEnv.JIRA_PROJECT_KEY}" AND status = "${status}" AND key = "${ticketKey}"`; + const data = await jiraRequest( + `/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&fields=summary&maxResults=1`, + ).catch(() => null); + const issues = data?.issues ?? []; + return issues.some((i: { key?: string }) => i.key === ticketKey); +} + export async function getTicketComments( ticketKey: string, ): Promise> { diff --git a/e2e/helpers/sandbox.ts b/e2e/helpers/sandbox.ts index 612ff09..08df96b 100644 --- a/e2e/helpers/sandbox.ts +++ b/e2e/helpers/sandbox.ts @@ -48,6 +48,14 @@ export async function stopSandboxesForTicket( * after claude exits, so killing claude causes the workflow's pollUntilDone * to see the sentinel with empty/partial stdout — parseResearchStatus then * defaults to `{ status: "failed" }`, exercising the US-7 failure path. + * + * Returns `true` only when `pkill` actually terminated a claude process. + * Returning `true` from "sandbox exists on the right branch" alone is unsafe: + * there's a window between git checkout and claude exec where the wrapper is + * still sourcing env files — `pkill` then matches nothing (exit 1), claude + * starts a moment later, the agent runs to completion, and the ticket lands + * in AI Review instead of Backlog. Caller polls this helper, so returning + * `false` on a no-op pkill makes the caller try again instead of advancing. */ export async function killClaudeForTicket( ticketKey: string, @@ -73,8 +81,17 @@ export async function killClaudeForTicket( : null; if (branch !== expectedBranch) continue; - await sandbox.runCommand({ cmd: "pkill", args: ["-9", "-f", "claude"] }); - return true; + // `pkill` exits 0 if any process matched and was signaled, 1 if no + // match. We only claim success on 0 — that guarantees the wrapper's + // foreground pipeline was actually interrupted and the cleanup path + // (touch sentinel with empty stdout) will run. + const killResult = await sandbox.runCommand({ + cmd: "pkill", + args: ["-9", "-f", "claude"], + }); + if (killResult.exitCode === 0) return true; + // Matched sandbox but claude wasn't running yet; caller will retry. + return false; } return false; } diff --git a/e2e/tier2/us08-previously-failed-skip.test.ts b/e2e/tier2/us08-previously-failed-skip.test.ts index 58c2653..344bee6 100644 --- a/e2e/tier2/us08-previously-failed-skip.test.ts +++ b/e2e/tier2/us08-previously-failed-skip.test.ts @@ -3,6 +3,7 @@ import { createTestTicket, moveTicketToColumn, getTicketStatus, + isTicketVisibleInJql, deleteTicket, } from "../helpers/jira.js"; import { findPR, deleteBranch } from "../helpers/github.js"; @@ -67,6 +68,21 @@ describe("US-08: Previously-failed ticket is skipped", () => { // because the failure marker is present. await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + // 3b. Wait until the ticket actually shows up in a JQL search for the + // AI column. Jira's search index lags transitions by seconds; if we + // poke cron before it catches up, reconcile's failed-marker loop + // will see our ticket absent from `aiColumnTickets` and clear the + // marker we just seeded — collapsing the whole assertion. + await waitFor( + async () => + (await isTicketVisibleInJql(ticketKey, e2eEnv.COLUMN_AI)) ? true : null, + { + description: `JQL visibility for ${ticketKey} in ${e2eEnv.COLUMN_AI}`, + timeoutMs: 60_000, + intervalMs: 2_000, + }, + ); + // 4. Explicitly poke the cron re-poll path — this is the scenario US-8 // is primarily about. Cron discovers the ticket (still in AI), calls // dispatchTicket, and the failed-marker precheck returns From 6bdad20cc8824668dad1bb683785b144f59d97a1 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 14:35:38 +0200 Subject: [PATCH 48/71] feat: optimize sandbox delete --- e2e/vitest.e2e.config.ts | 33 ++++++++-- package.json | 6 +- src/adapters/run-registry/types.ts | 12 +++- src/adapters/run-registry/upstash.ts | 18 +++++- src/lib/cancel-run.test.ts | 6 +- src/lib/cancel-run.ts | 7 +- src/lib/dispatch.test.ts | 4 ++ src/lib/reconcile.test.ts | 6 +- src/lib/reconcile.ts | 17 +++-- src/routes/webhooks/jira.post.ts | 3 +- src/sandbox/stop-ticket-sandboxes.ts | 95 ++++++++++++++++++++-------- src/workflows/agent.ts | 11 ++++ 12 files changed, 169 insertions(+), 49 deletions(-) diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 921a77f..2b0330f 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -24,8 +24,16 @@ export default defineConfig({ globals: true, environment: "node", include: ["e2e/**/*.test.ts"], + // Within-file tests stay serial — each test owns setup/teardown and + // concurrent ordering inside one file buys nothing here. sequence: { concurrent: false }, - fileParallelism: false, + // Enable cross-file parallelism; `maxWorkers` caps how many files run + // simultaneously. The `test:e2e:tier2:parallel` script relies on this; + // `tier2-capacity` has a single file, so this flag is a no-op for it; + // tier1 is currently empty. + fileParallelism: true, + maxWorkers: 6, + minWorkers: 1, projects: [ { test: { @@ -36,13 +44,28 @@ export default defineConfig({ }, }, { + // Most of tier2 can run concurrently: each test owns a unique + // ticket key, branch name, and Redis field. Excludes US-11, which + // asserts on the *global* MAX_CONCURRENT_AGENTS cap — if other + // tests are holding claim slots while it runs, US-11 sees fewer + // than max of its own tickets claimed and fails. test: { - name: "tier2", + name: "tier2-parallel", include: ["e2e/tier2/**/*.test.ts"], + exclude: ["e2e/tier2/us11-*.test.ts"], + testTimeout: 2_100_000, + hookTimeout: 2_100_000, + }, + }, + { + // US-11 runs alone so the capacity cap reflects only its own + // tickets. Invoked via a separate `vitest run --project` call + // after tier2-parallel finishes (see package.json scripts), so no + // other tier2 files hold Redis claim slots while it runs. + test: { + name: "tier2-capacity", + include: ["e2e/tier2/us11-*.test.ts"], testTimeout: 2_100_000, - // afterAll often iterates Sandbox.list() + runs commands inside - // each matching sandbox to confirm branch, which easily exceeds - // the 10s default. Mirror testTimeout so cleanup doesn't abort. hookTimeout: 2_100_000, }, }, diff --git a/package.json b/package.json index 30e82c3..6e041d2 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "test:e2e": "vitest run --config e2e/vitest.e2e.config.ts", + "test:e2e": "pnpm test:e2e:tier1 && pnpm test:e2e:tier2", "test:e2e:tier1": "vitest run --config e2e/vitest.e2e.config.ts --project tier1", - "test:e2e:tier2": "vitest run --config e2e/vitest.e2e.config.ts --project tier2" + "test:e2e:tier2": "pnpm test:e2e:tier2:parallel && pnpm test:e2e:tier2:capacity", + "test:e2e:tier2:parallel": "vitest run --config e2e/vitest.e2e.config.ts --project tier2-parallel", + "test:e2e:tier2:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project tier2-capacity" }, "dependencies": { "@chat-adapter/slack": "^4.20.2", diff --git a/src/adapters/run-registry/types.ts b/src/adapters/run-registry/types.ts index 2db99d8..264f094 100644 --- a/src/adapters/run-registry/types.ts +++ b/src/adapters/run-registry/types.ts @@ -11,11 +11,21 @@ export interface RunRegistryAdapter { register(ticketKey: string, runId: string): Promise; /** Get the runId for a ticket, or null if none registered. */ getRunId(ticketKey: string): Promise; - /** Remove the ticket -> runId mapping. */ + /** Remove the ticket -> runId mapping (also clears any linked sandboxId). */ unregister(ticketKey: string): Promise; /** Get all tracked ticket -> runId pairs. */ listAll(): Promise>; + /** + * Record the sandboxId that backs this ticket's workflow. Lets cleanup + * paths (reconcile, cancelRun, webhook-cancel) stop the sandbox by id + * instead of listing all sandboxes and inspecting each one's checked-out + * branch. + */ + registerSandbox(ticketKey: string, sandboxId: string): Promise; + /** Get the sandboxId for a ticket, or null if none registered. */ + getSandboxId(ticketKey: string): Promise; + /** Mark a ticket as failed (moveTicket to backlog failed in catch block). */ markFailed(ticketKey: string, meta: FailedTicketMeta): Promise; /** Check if a ticket has a failure marker. */ diff --git a/src/adapters/run-registry/upstash.ts b/src/adapters/run-registry/upstash.ts index 4449721..78d5221 100644 --- a/src/adapters/run-registry/upstash.ts +++ b/src/adapters/run-registry/upstash.ts @@ -4,6 +4,7 @@ import type { RunRegistryAdapter, FailedTicketMeta } from "./types.js"; const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; const HASH_KEY = `blazebot:active-runs:${ENV_PREFIX}`; const FAILED_HASH_KEY = `blazebot:failed-tickets:${ENV_PREFIX}`; +const SANDBOX_HASH_KEY = `blazebot:sandboxes:${ENV_PREFIX}`; export class UpstashRunRegistry implements RunRegistryAdapter { private redis: Redis; @@ -28,7 +29,13 @@ export class UpstashRunRegistry implements RunRegistryAdapter { } async unregister(ticketKey: string): Promise { - await this.redis.hdel(HASH_KEY, ticketKey); + // Clear both hashes in one round-trip. The sandbox link is useless + // without the run entry, and callers expect unregister() to fully + // detach the ticket. + await Promise.all([ + this.redis.hdel(HASH_KEY, ticketKey), + this.redis.hdel(SANDBOX_HASH_KEY, ticketKey), + ]); } async listAll(): Promise> { @@ -37,6 +44,15 @@ export class UpstashRunRegistry implements RunRegistryAdapter { return Object.entries(all).map(([ticketKey, runId]) => ({ ticketKey, runId })); } + async registerSandbox(ticketKey: string, sandboxId: string): Promise { + await this.redis.hset(SANDBOX_HASH_KEY, { [ticketKey]: sandboxId }); + await this.redis.persist(SANDBOX_HASH_KEY); + } + + async getSandboxId(ticketKey: string): Promise { + return this.redis.hget(SANDBOX_HASH_KEY, ticketKey); + } + async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { await this.redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); } diff --git a/src/lib/cancel-run.test.ts b/src/lib/cancel-run.test.ts index 18aa0a6..fcf3545 100644 --- a/src/lib/cancel-run.test.ts +++ b/src/lib/cancel-run.test.ts @@ -17,6 +17,8 @@ function makeRegistry(overrides: Partial = {}): RunRegistryA getRunId: vi.fn(), unregister: overrides.unregister ?? vi.fn().mockResolvedValue(undefined), listAll: vi.fn(), + registerSandbox: vi.fn().mockResolvedValue(undefined), + getSandboxId: overrides.getSandboxId ?? vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), @@ -41,7 +43,7 @@ describe("cancelRun", () => { expect(result).toBe(true); expect(mockGetRun).toHaveBeenCalledWith("run_abc"); expect(mockCancel).toHaveBeenCalled(); - expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1", null); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); }); @@ -55,7 +57,7 @@ describe("cancelRun", () => { const result = await cancelRun("PROJ-1", "run_abc", registry); expect(result).toBe(false); - expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1", null); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); }); diff --git a/src/lib/cancel-run.ts b/src/lib/cancel-run.ts index 2c54e32..491b8d6 100644 --- a/src/lib/cancel-run.ts +++ b/src/lib/cancel-run.ts @@ -25,7 +25,12 @@ export async function cancelRun( ); } - await stopTicketSandboxes(ticketKey).catch(() => {}); + // Look up the sandboxId first so the stop path is O(1) instead of a + // branch-scan across every running sandbox. Best-effort — if this + // lookup errors or returns null, stopTicketSandboxes falls back to the + // parallel branch scan. + const sandboxId = await runRegistry.getSandboxId(ticketKey).catch(() => null); + await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {}); await runRegistry.unregister(ticketKey).catch(() => {}); return cancelled; } diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 640450f..26bea30 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -88,6 +88,8 @@ function makeAdapters( overrides.getRunId ?? vi.fn().mockImplementation(async () => claimedValue), listAll: overrides.listAll ?? vi.fn().mockResolvedValue([]), + registerSandbox: vi.fn().mockResolvedValue(undefined), + getSandboxId: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), @@ -430,6 +432,8 @@ describe("failed-ticket safeguard full loop", () => { getRunId: vi.fn().mockImplementation(async () => claimedValue), unregister: vi.fn().mockResolvedValue(undefined), listAll: vi.fn().mockResolvedValue([]), + registerSandbox: vi.fn().mockResolvedValue(undefined), + getSandboxId: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockImplementation(async (key: string, meta: any) => { failedMarkers.set(key, JSON.stringify(meta)); }), diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index 2a69697..f48181f 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -37,6 +37,8 @@ function makeRegistry( getRunId: vi.fn(), unregister: vi.fn().mockResolvedValue(undefined), listAll: vi.fn().mockResolvedValue(runs), + registerSandbox: vi.fn().mockResolvedValue(undefined), + getSandboxId: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue(failed), @@ -89,7 +91,7 @@ describe("reconcileRuns", () => { expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); // A crash between dispatch.start() and dispatch.register() can leave // a live sandbox shadowed by a sentinel; reconcile must sweep it. - expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1", null); }); @@ -105,7 +107,7 @@ describe("reconcileRuns", () => { expect(result).toEqual({ cancelled: 1, cleaned: 0 }); expect(registry.unregister).toHaveBeenCalledWith("PROJ-1"); expect(mockCancelRun).not.toHaveBeenCalled(); - expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1"); + expect(mockStopTicketSandboxes).toHaveBeenCalledWith("PROJ-1", null); }); it("keeps fresh claiming entry when missing from JQL snapshot but Jira still says AI", async () => { diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index 41cd568..d3f1455 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -153,8 +153,13 @@ async function reconcileInflightClaim( // research phase) *before* overwriting the sentinel with the real // runId. A crash in that narrow window leaves a sentinel in Redis // alongside a running sandbox we have no way to cancel via the - // workflow handle. Sweep any matching sandbox by branch name. - await stopTicketSandboxes(ticketKey).catch(() => {}); + // workflow handle. Try the fast path (sandboxId from Redis); fall + // back to the parallel branch scan if the workflow crashed before + // writing its sandboxId. + const sandboxId = await runRegistry + .getSandboxId(ticketKey) + .catch(() => null); + await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {}); await runRegistry.unregister(ticketKey); logger.warn({ ticketKey, runId }, "reconcile_cleaned_stale_claim"); return { cancelled: 0, cleaned: 1 }; @@ -163,10 +168,10 @@ async function reconcileInflightClaim( if (ticketLeftAiColumn) { const leftAiColumn = await verifyTicketLeftAiColumn(ticketKey, issueTracker); if (!leftAiColumn) return { cancelled: 0, cleaned: 0 }; - // Same rationale as the stale-claim branch: a sentinel can shadow a - // real sandbox if dispatch crashed mid-start. Stop by branch before - // unregistering so the cancellation is complete. - await stopTicketSandboxes(ticketKey).catch(() => {}); + const sandboxId = await runRegistry + .getSandboxId(ticketKey) + .catch(() => null); + await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {}); await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId }, "reconcile_cancelled_inflight_claim"); await notifyTicketCancelled(ticketKey, "inflight_claim", onTicketCancelled); diff --git a/src/routes/webhooks/jira.post.ts b/src/routes/webhooks/jira.post.ts index 9036170..bc946cb 100644 --- a/src/routes/webhooks/jira.post.ts +++ b/src/routes/webhooks/jira.post.ts @@ -229,7 +229,8 @@ async function cancelTrackedRun( // start() but crashed before register(). Same gap that reconcile's // stale-claim sweep covers — we catch it here on the faster webhook // path so operators don't have to wait 5 minutes for reconcile. - await stopTicketSandboxes(ticketKey).catch(() => {}); + const sandboxId = await runRegistry.getSandboxId(ticketKey).catch(() => null); + await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {}); await runRegistry.unregister(ticketKey).catch(() => {}); return true; } diff --git a/src/sandbox/stop-ticket-sandboxes.ts b/src/sandbox/stop-ticket-sandboxes.ts index 25e0226..38cbbbf 100644 --- a/src/sandbox/stop-ticket-sandboxes.ts +++ b/src/sandbox/stop-ticket-sandboxes.ts @@ -5,47 +5,86 @@ const BRANCH_PREFIX = "blazebot/"; /** * Best-effort cleanup for leaked sandboxes after ticket cancellation. - * Finds running sandboxes whose checked-out branch matches the ticket branch - * and requests stop on each match. + * + * Fast path: if the caller knows the sandboxId (looked up from Redis via + * `runRegistry.getSandboxId`), we issue a single `Sandbox.stop()` — no + * discovery pass at all. + * + * Fallback path: scan all running sandboxes and inspect each one's checked- + * out branch. Used when the caller doesn't have a sandboxId (older Redis + * state, or a crash between `provisionSandbox` and the sandboxId being + * written). The scan runs in parallel — serial iteration over N sandboxes + * previously dominated cron's 300s budget when the environment was busy. */ -export async function stopTicketSandboxes(ticketKey: string): Promise { +export async function stopTicketSandboxes( + ticketKey: string, + knownSandboxId?: string | null, +): Promise { const normalizedTicket = ticketKey.trim().toLowerCase(); if (!normalizedTicket) return 0; + const { Sandbox } = await import("@vercel/sandbox"); + const credentials = getSandboxCredentials(); + + if (knownSandboxId) { + try { + const sandbox = await Sandbox.get({ + ...credentials, + sandboxId: knownSandboxId, + }); + if (sandbox.status === "running") { + await sandbox.stop(); + logger.info( + { ticketKey, sandboxId: knownSandboxId }, + "cancel_run_stopped_known_sandbox", + ); + return 1; + } + return 0; + } catch (err) { + logger.warn( + { ticketKey, sandboxId: knownSandboxId, error: (err as Error).message }, + "cancel_run_known_sandbox_stop_failed", + ); + // Fall through to branch scan in case the id is stale. + } + } + const expectedBranch = `${BRANCH_PREFIX}${normalizedTicket}`; try { - const { Sandbox } = await import("@vercel/sandbox"); - const credentials = getSandboxCredentials(); const { json } = await Sandbox.list({ ...credentials, limit: 100 }); const running = json.sandboxes.filter((sandbox) => sandbox.status === "running"); - let stopped = 0; - for (const entry of running) { - try { - const sandbox = await Sandbox.get({ - ...credentials, - sandboxId: entry.id, - }); - if (sandbox.status !== "running") continue; + const results = await Promise.all( + running.map(async (entry): Promise => { + try { + const sandbox = await Sandbox.get({ + ...credentials, + sandboxId: entry.id, + }); + if (sandbox.status !== "running") return 0; - const branch = await getSandboxBranch(sandbox); - if (branch !== expectedBranch) continue; + const branch = await getSandboxBranch(sandbox); + if (branch !== expectedBranch) return 0; - await sandbox.stop(); - stopped++; - } catch (err) { - logger.warn( - { - ticketKey, - sandboxId: entry.id, - error: (err as Error).message, - }, - "cancel_run_sandbox_stop_failed", - ); - } - } + await sandbox.stop(); + return 1; + } catch (err) { + logger.warn( + { + ticketKey, + sandboxId: entry.id, + error: (err as Error).message, + }, + "cancel_run_sandbox_stop_failed", + ); + return 0; + } + }), + ); + const stopped = results.reduce((a, b) => a + b, 0); if (stopped > 0) { logger.info( { ticketKey, expectedBranch, stopped }, diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 732cf56..45bc53d 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -271,6 +271,13 @@ async function unregisterRun(ticketIdentifier: string) { await runRegistry.unregister(ticketIdentifier); } +async function registerTicketSandbox(ticketIdentifier: string, sandboxId: string) { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { runRegistry } = createStepAdapters(); + await runRegistry.registerSandbox(ticketIdentifier, sandboxId); +} + async function markTicketFailed(ticketIdentifier: string, error: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); @@ -355,6 +362,10 @@ export async function agentWorkflow(ticketId: string) { // Provision sandbox once for all phases const sandboxId = await provisionSandbox(branchName, mergeBase); + // Pin the sandboxId to this ticket so cleanup paths (reconcile, + // cancelRun, webhook-cancel) can stop it by id instead of doing a + // branch scan across every running sandbox. + await registerTicketSandbox(ticket.identifier, sandboxId); try { await writeAttachments(sandboxId, downloadedAttachments); From 0cc8f0f5962635aae236a28fe4cf23b94e79c87e Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 17 Apr 2026 14:50:53 +0200 Subject: [PATCH 49/71] fix: concurrent tests flow --- e2e/helpers/redis.ts | 12 ++++- e2e/tier2/us09-failed-marker-cleared.test.ts | 8 +++- e2e/vitest.e2e.config.ts | 5 +- src/adapters/run-registry/types.ts | 10 ++++ src/adapters/run-registry/upstash.test.ts | 7 +++ src/adapters/run-registry/upstash.ts | 32 +++++++++++-- src/lib/cancel-run.test.ts | 1 + src/lib/dispatch.test.ts | 2 + src/lib/reconcile.test.ts | 1 + src/lib/reconcile.ts | 48 ++++++++++++++++++-- 10 files changed, 113 insertions(+), 13 deletions(-) diff --git a/e2e/helpers/redis.ts b/e2e/helpers/redis.ts index 2a8675c..8e4247e 100644 --- a/e2e/helpers/redis.ts +++ b/e2e/helpers/redis.ts @@ -3,6 +3,8 @@ import { e2eEnv } from "../env.js"; const HASH_KEY = `blazebot:active-runs:${e2eEnv.VERCEL_ENV}`; const FAILED_HASH_KEY = `blazebot:failed-tickets:${e2eEnv.VERCEL_ENV}`; +const SANDBOX_HASH_KEY = `blazebot:sandboxes:${e2eEnv.VERCEL_ENV}`; +const ENTRY_TS_HASH_KEY = `blazebot:entry-timestamps:${e2eEnv.VERCEL_ENV}`; const redis = new Redis({ url: e2eEnv.AI_WORKFLOW_KV_REST_API_URL, @@ -30,10 +32,18 @@ export async function setEntry( runId: string, ): Promise { await redis.hset(HASH_KEY, { [ticketKey]: runId }); + // Mirror the production adapter: stamp a creation timestamp so + // reconcile's orphan grace window (src/lib/reconcile.ts:ORPHAN_GRACE_MS) + // treats the seeded entry as fresh, not as stale junk to clean up. + await redis.hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }); } export async function cleanup(ticketKey: string): Promise { - await redis.hdel(HASH_KEY, ticketKey).catch(() => {}); + await Promise.all([ + redis.hdel(HASH_KEY, ticketKey).catch(() => {}), + redis.hdel(SANDBOX_HASH_KEY, ticketKey).catch(() => {}), + redis.hdel(ENTRY_TS_HASH_KEY, ticketKey).catch(() => {}), + ]); } export interface FailedTicketMeta { diff --git a/e2e/tier2/us09-failed-marker-cleared.test.ts b/e2e/tier2/us09-failed-marker-cleared.test.ts index 01428ba..f661e66 100644 --- a/e2e/tier2/us09-failed-marker-cleared.test.ts +++ b/e2e/tier2/us09-failed-marker-cleared.test.ts @@ -56,11 +56,15 @@ describe("US-09: Failed marker cleared when ticket leaves AI", () => { await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); - // 2. Seed a failure marker in Redis + // 2. Seed a failure marker in Redis. Backdate failedAt to sit outside + // the reconcile grace window (ORPHAN_GRACE_MS in reconcile.ts) so + // reconcile clears it on the first pass rather than treating it as + // a just-seeded, mid-transition marker. + const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString(); await markFailed(ticketKey, { runId: "run_e2e_seeded", error: "seeded by e2e test", - failedAt: new Date().toISOString(), + failedAt: oneMinuteAgo, }); expect(await isTicketFailed(ticketKey)).toBe(true); diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 2b0330f..56a2f4b 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -12,7 +12,10 @@ if (existsSync(envPath)) { if (idx === -1) continue; const key = trimmed.slice(0, idx).trim(); let value = trimmed.slice(idx + 1).trim(); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; diff --git a/src/adapters/run-registry/types.ts b/src/adapters/run-registry/types.ts index 264f094..1e75d01 100644 --- a/src/adapters/run-registry/types.ts +++ b/src/adapters/run-registry/types.ts @@ -26,6 +26,16 @@ export interface RunRegistryAdapter { /** Get the sandboxId for a ticket, or null if none registered. */ getSandboxId(ticketKey: string): Promise; + /** + * Wall-clock timestamp (ms since epoch) when the ticket's current entry + * was first recorded, or null if unknown. Reconcile uses this to skip + * cleanup of entries that look like orphans but are actually mid- + * transition — without this, a cron tick that fires between a ticket + * entering the registry and its Jira transition completing would wipe + * the entry as a "stale orphan". + */ + getEntryCreatedAt(ticketKey: string): Promise; + /** Mark a ticket as failed (moveTicket to backlog failed in catch block). */ markFailed(ticketKey: string, meta: FailedTicketMeta): Promise; /** Check if a ticket has a failure marker. */ diff --git a/src/adapters/run-registry/upstash.test.ts b/src/adapters/run-registry/upstash.test.ts index 64251ee..62fa496 100644 --- a/src/adapters/run-registry/upstash.test.ts +++ b/src/adapters/run-registry/upstash.test.ts @@ -26,6 +26,13 @@ function createRegistry() { describe("UpstashRunRegistry", () => { beforeEach(() => { vi.clearAllMocks(); + // Default hset to resolve so the adapter's best-effort timestamp + // writes (to ENTRY_TS_HASH_KEY / SANDBOX_HASH_KEY) don't blow up with + // "cannot read .catch of undefined" in tests that only cared about + // the primary HASH_KEY call. + mockRedis.hset.mockResolvedValue(1); + mockRedis.hdel.mockResolvedValue(1); + mockRedis.persist.mockResolvedValue(1); }); describe("claim", () => { diff --git a/src/adapters/run-registry/upstash.ts b/src/adapters/run-registry/upstash.ts index 78d5221..8c07ffe 100644 --- a/src/adapters/run-registry/upstash.ts +++ b/src/adapters/run-registry/upstash.ts @@ -5,6 +5,7 @@ const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; const HASH_KEY = `blazebot:active-runs:${ENV_PREFIX}`; const FAILED_HASH_KEY = `blazebot:failed-tickets:${ENV_PREFIX}`; const SANDBOX_HASH_KEY = `blazebot:sandboxes:${ENV_PREFIX}`; +const ENTRY_TS_HASH_KEY = `blazebot:entry-timestamps:${ENV_PREFIX}`; export class UpstashRunRegistry implements RunRegistryAdapter { private redis: Redis; @@ -15,13 +16,26 @@ export class UpstashRunRegistry implements RunRegistryAdapter { async claim(ticketKey: string, runId: string): Promise { const result = await this.redis.hsetnx(HASH_KEY, ticketKey, runId); - return result === 1; + if (result !== 1) return false; + // Stamp creation time so reconcile can tell a just-written entry from + // a genuine orphan. Best-effort — if this write fails, reconcile just + // falls back to treating the entry as ageless (cleanup-eligible). + await this.redis + .hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }) + .catch(() => {}); + return true; } async register(ticketKey: string, runId: string): Promise { await this.redis.hset(HASH_KEY, { [ticketKey]: runId }); // Ensure the hash has no expiry — defend against external TTL being set await this.redis.persist(HASH_KEY); + // Refresh the creation timestamp: register() is called both on the + // initial claim → runId swap and by external seeders, so it's the + // authoritative write point. + await this.redis + .hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }) + .catch(() => {}); } async getRunId(ticketKey: string): Promise { @@ -29,12 +43,12 @@ export class UpstashRunRegistry implements RunRegistryAdapter { } async unregister(ticketKey: string): Promise { - // Clear both hashes in one round-trip. The sandbox link is useless - // without the run entry, and callers expect unregister() to fully - // detach the ticket. + // Clear all three hashes in one round-trip. Each is useless without + // the others, and callers expect unregister() to fully detach. await Promise.all([ this.redis.hdel(HASH_KEY, ticketKey), this.redis.hdel(SANDBOX_HASH_KEY, ticketKey), + this.redis.hdel(ENTRY_TS_HASH_KEY, ticketKey), ]); } @@ -53,6 +67,16 @@ export class UpstashRunRegistry implements RunRegistryAdapter { return this.redis.hget(SANDBOX_HASH_KEY, ticketKey); } + async getEntryCreatedAt(ticketKey: string): Promise { + const raw = await this.redis.hget( + ENTRY_TS_HASH_KEY, + ticketKey, + ); + if (raw == null) return null; + const parsed = typeof raw === "number" ? raw : parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + } + async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { await this.redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); } diff --git a/src/lib/cancel-run.test.ts b/src/lib/cancel-run.test.ts index fcf3545..b029589 100644 --- a/src/lib/cancel-run.test.ts +++ b/src/lib/cancel-run.test.ts @@ -19,6 +19,7 @@ function makeRegistry(overrides: Partial = {}): RunRegistryA listAll: vi.fn(), registerSandbox: vi.fn().mockResolvedValue(undefined), getSandboxId: overrides.getSandboxId ?? vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index 26bea30..1990a49 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -90,6 +90,7 @@ function makeAdapters( listAll: overrides.listAll ?? vi.fn().mockResolvedValue([]), registerSandbox: vi.fn().mockResolvedValue(undefined), getSandboxId: vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), @@ -434,6 +435,7 @@ describe("failed-ticket safeguard full loop", () => { listAll: vi.fn().mockResolvedValue([]), registerSandbox: vi.fn().mockResolvedValue(undefined), getSandboxId: vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockImplementation(async (key: string, meta: any) => { failedMarkers.set(key, JSON.stringify(meta)); }), diff --git a/src/lib/reconcile.test.ts b/src/lib/reconcile.test.ts index f48181f..2bf205e 100644 --- a/src/lib/reconcile.test.ts +++ b/src/lib/reconcile.test.ts @@ -39,6 +39,7 @@ function makeRegistry( listAll: vi.fn().mockResolvedValue(runs), registerSandbox: vi.fn().mockResolvedValue(undefined), getSandboxId: vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), markFailed: vi.fn().mockResolvedValue(undefined), isTicketFailed: vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue(failed), diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts index d3f1455..86d2200 100644 --- a/src/lib/reconcile.ts +++ b/src/lib/reconcile.ts @@ -13,6 +13,16 @@ import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); const STALE_CLAIM_MS = 5 * 60 * 1000; +/** + * Grace period applied to any cleanup that relies on "ticket isn't in the + * AI-column snapshot." Jira's JQL index lags transitions by seconds, and + * dispatch writes the registry entry before the transition commits, so a + * ticket genuinely moving INTO AI can briefly look like an orphan. Skip + * anything younger than this — reconcile runs every minute, so we'll pick + * up a real orphan on the next tick. + */ +const ORPHAN_GRACE_MS = 30 * 1000; + /** * Track consecutive getRun failures per ticket. * Only unregister after UNREACHABLE_STRIKES_LIMIT consecutive failures @@ -55,6 +65,13 @@ export async function reconcileRuns( if (ticketStillInAiColumn) { cleaned += await cleanFinishedRun(ticketKey, runId, runRegistry); } else { + if (await isWithinGracePeriod(ticketKey, runRegistry)) { + logger.info( + { ticketKey, runId }, + "reconcile_skipped_fresh_orphan_in_grace", + ); + continue; + } const leftAiColumn = await verifyTicketLeftAiColumn(ticketKey, issueTracker); if (!leftAiColumn) continue; await cancelRun(ticketKey, runId, runRegistry); @@ -64,18 +81,39 @@ export async function reconcileRuns( } } - // Clean up failed-ticket markers for tickets that left the AI column + // Clean up failed-ticket markers for tickets that left the AI column. + // Respect the same grace window: a marker that was just written while + // the ticket is mid-transition shouldn't be wiped on the first cron + // tick that catches it between columns. const failedTickets = await runRegistry.listAllFailed(); - for (const { ticketKey } of failedTickets) { - if (!aiColumnTickets.has(ticketKey)) { - await runRegistry.clearFailedMark(ticketKey); - logger.info({ ticketKey }, "reconcile_cleared_failed_mark"); + for (const { ticketKey, meta } of failedTickets) { + if (aiColumnTickets.has(ticketKey)) continue; + const failedAtMs = Date.parse(meta.failedAt); + if (Number.isFinite(failedAtMs) && Date.now() - failedAtMs < ORPHAN_GRACE_MS) { + logger.info( + { ticketKey, failedAt: meta.failedAt }, + "reconcile_skipped_fresh_failed_marker_in_grace", + ); + continue; } + await runRegistry.clearFailedMark(ticketKey); + logger.info({ ticketKey }, "reconcile_cleared_failed_mark"); } return { cancelled, cleaned }; } +async function isWithinGracePeriod( + ticketKey: string, + runRegistry: RunRegistryAdapter, +): Promise { + const createdAt = await runRegistry + .getEntryCreatedAt(ticketKey) + .catch(() => null); + if (createdAt == null) return false; + return Date.now() - createdAt < ORPHAN_GRACE_MS; +} + async function verifyTicketLeftAiColumn( ticketKey: string, issueTracker?: IssueTrackerAdapter, From 8c369ef9d3741de1ccd6d928636064e0235eed61 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 11:56:24 +0200 Subject: [PATCH 50/71] feat: better multi ticket dispatch --- src/adapters/messaging/chatsdk.ts | 4 ++++ src/routes/cron/poll.get.ts | 37 +++++++++++++++++-------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/adapters/messaging/chatsdk.ts b/src/adapters/messaging/chatsdk.ts index 4de7353..1aeabc0 100644 --- a/src/adapters/messaging/chatsdk.ts +++ b/src/adapters/messaging/chatsdk.ts @@ -48,6 +48,10 @@ export class ChatSDKAdapter implements MessagingAdapter { try { const channel = this.chat.channel(`slack:${this.channelId}`); await channel.post(message); + logger.info( + { channelId: this.channelId, messagePreview: message.slice(0, 120) }, + "notification_sent", + ); } catch (err) { logger.warn( { error: (err as Error).message }, diff --git a/src/routes/cron/poll.get.ts b/src/routes/cron/poll.get.ts index 0fd4b88..c4718eb 100644 --- a/src/routes/cron/poll.get.ts +++ b/src/routes/cron/poll.get.ts @@ -68,24 +68,27 @@ async function dispatchDiscoveredTickets( ticketKeys: string[], adapters: ReturnType, ): Promise { - const started: string[] = []; - - for (const key of ticketKeys) { - let result: Awaited>; - try { - result = await dispatchTicket(key, adapters, env.MAX_CONCURRENT_AGENTS); - } catch (err) { - logger.warn( - { ticketKey: key, error: err }, - "poll_dispatch_failed", - ); - break; - } - if (result.started) started.push(key); - if (result.reason === "at_capacity") break; - } + // Dispatch in parallel. dispatchTicket is internally atomic — the + // post-claim fairness check in src/lib/dispatch.ts caps started + // workflows at MAX_CONCURRENT_AGENTS even when racers run concurrently, + // so excess parallel dispatches safely return `at_capacity`. + const results = await Promise.all( + ticketKeys.map(async (key) => { + try { + const result = await dispatchTicket( + key, + adapters, + env.MAX_CONCURRENT_AGENTS, + ); + return { key, started: result.started }; + } catch (err) { + logger.warn({ ticketKey: key, error: err }, "poll_dispatch_failed"); + return { key, started: false }; + } + }), + ); - return started; + return results.filter((r) => r.started).map((r) => r.key); } function normalizeTicketKeys(ticketKeys: string[]): string[] { From e2c3b1a033bca0aac4a3a45f9c749c6ec5f9ca22 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 12:12:13 +0200 Subject: [PATCH 51/71] feat: disable review --- src/workflows/agent.ts | 91 +++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 45bc53d..72bb64c 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -496,51 +496,52 @@ export async function agentWorkflow(ticketId: string) { } // ========== PHASE 3: Review ========== - await configureStopHook(sandboxId, true); - - const gitDiff = await captureGitDiff(sandboxId); - - const reviewInput = assembleReviewContext({ - ticket: ticketData, - prompt: getPrompt("review.md"), - researchPlanMarkdown, - gitDiff, - attachments: downloadedAttachments, - }); - - const reviewScript = buildPhaseScript({ - model: env.CLAUDE_MODEL, - phase: "review", - inputFile: "/tmp/review-requirements.md", - outputFile: "/tmp/review-stdout.txt", - stderrFile: "/tmp/review-stderr.txt", - sentinelFile: "/tmp/review-done", - jsonSchema: REVIEW_SCHEMA, - }); - - await writeAndStartPhase( - sandboxId, - "/tmp/review-requirements.md", reviewInput, - "/tmp/review-wrapper.sh", reviewScript, - ); - - const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); - let reviewOutput: ReviewOutput; - - if (reviewDone) { - const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); - phaseUsages["Review"] = extractUsage(reviewRaw); - reviewOutput = parseReviewOutput(reviewRaw); - } else { - reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; - } - - if (reviewOutput.result === "failed") { - await moveTicket(ticketId, env.COLUMN_BACKLOG); - await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}${usageSuffix()}`); - await unregisterRun(ticket.identifier); - return; - } + // Temporarily disabled. + // await configureStopHook(sandboxId, true); + // + // const gitDiff = await captureGitDiff(sandboxId); + // + // const reviewInput = assembleReviewContext({ + // ticket: ticketData, + // prompt: getPrompt("review.md"), + // researchPlanMarkdown, + // gitDiff, + // attachments: downloadedAttachments, + // }); + // + // const reviewScript = buildPhaseScript({ + // model: env.CLAUDE_MODEL, + // phase: "review", + // inputFile: "/tmp/review-requirements.md", + // outputFile: "/tmp/review-stdout.txt", + // stderrFile: "/tmp/review-stderr.txt", + // sentinelFile: "/tmp/review-done", + // jsonSchema: REVIEW_SCHEMA, + // }); + // + // await writeAndStartPhase( + // sandboxId, + // "/tmp/review-requirements.md", reviewInput, + // "/tmp/review-wrapper.sh", reviewScript, + // ); + // + // const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); + // let reviewOutput: ReviewOutput; + // + // if (reviewDone) { + // const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); + // phaseUsages["Review"] = extractUsage(reviewRaw); + // reviewOutput = parseReviewOutput(reviewRaw); + // } else { + // reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; + // } + // + // if (reviewOutput.result === "failed") { + // await moveTicket(ticketId, env.COLUMN_BACKLOG); + // await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}${usageSuffix()}`); + // await unregisterRun(ticket.identifier); + // return; + // } // ========== POST-PHASES: Push & PR ========== let pushResult = await pushFromSandbox(sandboxId, branchName); From 3d9855c78b8b2904bc54cf340724d5ee94e07c2e Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 12:44:38 +0200 Subject: [PATCH 52/71] feat: new actions for e2e --- .github/workflows/e2e.yml | 113 ++++++++++++++++-- .../us10-duplicate-dispatch-prevented.test.ts | 12 +- .../us11-capacity-limit-respected.test.ts | 21 +++- e2e/vitest.e2e.config.ts | 52 ++++++-- package.json | 8 +- 5 files changed, 179 insertions(+), 27 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6470e09..e7ec705 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,17 +4,18 @@ on: workflow_dispatch: inputs: tier: - description: "Which tier to run" + description: "Which suite to run" type: choice options: - - tier1 - - tier2 + - agent + - orchestration + - capacity - all default: all jobs: e2e-tier1: - if: inputs.tier == 'tier1' || inputs.tier == 'all' + if: inputs.tier == 'all' runs-on: ubuntu-latest timeout-minutes: 15 environment: e2e @@ -54,11 +55,15 @@ jobs: e2e/**/*.log retention-days: 7 - e2e-tier2: - if: inputs.tier == 'tier2' || inputs.tier == 'all' + e2e-agent: needs: [e2e-tier1] + # Runs when picked alone (tier1 skipped) or as part of `all`. + if: | + always() && + (needs.e2e-tier1.result == 'success' || needs.e2e-tier1.result == 'skipped') && + (inputs.tier == 'agent' || inputs.tier == 'all') runs-on: ubuntu-latest - timeout-minutes: 150 + timeout-minutes: 120 environment: e2e env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} @@ -87,11 +92,101 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test:e2e:tier2 + - run: pnpm run test:e2e:agent - if: failure() uses: actions/upload-artifact@v4 with: - name: e2e-tier2-results + name: e2e-agent-results + path: | + e2e/**/*.log + retention-days: 7 + + e2e-orchestration: + needs: [e2e-agent] + if: | + always() && + (needs.e2e-agent.result == 'success' || needs.e2e-agent.result == 'skipped') && + (inputs.tier == 'orchestration' || inputs.tier == 'all') + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: e2e + env: + E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} + JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} + COLUMN_AI: ${{ secrets.COLUMN_AI }} + COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} + COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} + GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} + AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.12" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:e2e:orchestration + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-orchestration-results + path: | + e2e/**/*.log + retention-days: 7 + + e2e-capacity: + needs: [e2e-orchestration] + if: | + always() && + (needs.e2e-orchestration.result == 'success' || needs.e2e-orchestration.result == 'skipped') && + (inputs.tier == 'capacity' || inputs.tier == 'all') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: e2e + env: + E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} + JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} + COLUMN_AI: ${{ secrets.COLUMN_AI }} + COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} + COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} + GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} + AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.12" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:e2e:capacity + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-capacity-results path: | e2e/**/*.log retention-days: 7 diff --git a/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts b/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts index 3d65214..fb2c832 100644 --- a/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts +++ b/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts @@ -31,7 +31,17 @@ import { e2eEnv } from "../env.js"; * is atomic"). */ describe("US-10: Duplicate dispatch prevented by atomic claim", () => { - const SEEDED_RUN_ID = "run_e2e_us10_preexisting"; + // Seed as a claiming sentinel, not a literal runId. Reconcile's + // cleanFinishedRun path calls `getRun(runId).status` on non-sentinel + // values — with a fake runId that throws, the in-memory strike counter + // on a hot Vercel function instance reaches the unreachable-strikes + // limit quickly under parallel e2e load and unregisters the seed, + // breaking this test's assertion. Sentinels take the + // reconcileInflightClaim path, which leaves fresh (< STALE_CLAIM_MS) + // claims alone while the ticket is in AI. The dispatch guard itself + // (HSETNX on `claim()`) is agnostic to the value — any pre-existing + // entry blocks future claims. + const SEEDED_RUN_ID = `claiming:${Date.now()}`; let ticketKey: string; let branchName: string; diff --git a/e2e/tier2/us11-capacity-limit-respected.test.ts b/e2e/tier2/us11-capacity-limit-respected.test.ts index c5057b5..6577142 100644 --- a/e2e/tier2/us11-capacity-limit-respected.test.ts +++ b/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -11,7 +11,10 @@ import { listAll as listAllRuns, cleanup as redisCleanup, } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; +import { + stopSandboxesForTicket, + killClaudeForTicket, +} from "../helpers/sandbox.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; @@ -135,7 +138,21 @@ describe("US-11: Capacity limit respected", () => { const loserBranch = `blazebot/${loserKey.toLowerCase()}`; expect(await findPR(loserBranch)).toBeNull(); - // 6. Best-effort: capture any PRs the winners managed to open so cleanup + // 6. Capacity proven — kill claude in every winner sandbox so the + // test doesn't wait for full agent runs we don't care about. + // The workflow treats killed claude the same as US-7: sentinel + // with empty stdout → research `failed` → ticket moves to Backlog. + await Promise.all( + claimed.map(({ ticketKey }) => + waitFor(() => killClaudeForTicket(ticketKey), { + description: `kill claude in winner sandbox for ${ticketKey}`, + timeoutMs: 300_000, + intervalMs: 10_000, + }).catch(() => {}), + ), + ); + + // 7. Best-effort: capture any PRs the winners managed to open so cleanup // can close them. for (const t of tickets) { const pr = await findPR(t.branchName).catch(() => null); diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 56a2f4b..001b53b 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -31,8 +31,8 @@ export default defineConfig({ // concurrent ordering inside one file buys nothing here. sequence: { concurrent: false }, // Enable cross-file parallelism; `maxWorkers` caps how many files run - // simultaneously. The `test:e2e:tier2:parallel` script relies on this; - // `tier2-capacity` has a single file, so this flag is a no-op for it; + // simultaneously. The `agent` and `orchestration` projects rely on + // this; `capacity` has a single file, so this flag is a no-op for it; // tier1 is currently empty. fileParallelism: true, maxWorkers: 6, @@ -47,15 +47,45 @@ export default defineConfig({ }, }, { - // Most of tier2 can run concurrently: each test owns a unique - // ticket key, branch name, and Redis field. Excludes US-11, which - // asserts on the *global* MAX_CONCURRENT_AGENTS cap — if other - // tests are holding claim slots while it runs, US-11 sees fewer - // than max of its own tickets claimed and fails. + // Agent tests — provision real sandboxes and run Claude Code. + // Run these in parallel FIRST so expensive failures surface early + // and don't share a worker pool with cheap orchestration tests. + // - US-03: review-fix cycle (two full runs) + // - US-04: merge conflict rebase (full run) + // - US-06: clarification answered (two full runs) + // - US-07: agent failure — kills claude mid-run test: { - name: "tier2-parallel", + name: "agent", + include: [ + "e2e/tier2/us03-*.test.ts", + "e2e/tier2/us04-*.test.ts", + "e2e/tier2/us06-*.test.ts", + "e2e/tier2/us07-*.test.ts", + ], + testTimeout: 4_200_000, + hookTimeout: 4_200_000, + }, + }, + { + // Orchestration tests — redis/dispatch/reconcile paths, no Claude. + // Each test owns a unique ticket key, branch name, and Redis + // field, so they run cross-file parallel. Excludes: + // - US-11 — asserts the global MAX_CONCURRENT_AGENTS cap; runs + // alone via the `capacity` project. + // - US-01, US-05 — full agent runs, kept as reference only. + // - US-03, US-04, US-06, US-07 — agent tests, run via `agent`. + test: { + name: "orchestration", include: ["e2e/tier2/**/*.test.ts"], - exclude: ["e2e/tier2/us11-*.test.ts"], + exclude: [ + "e2e/tier2/us11-*.test.ts", + "e2e/tier2/us01-*.test.ts", + "e2e/tier2/us05-*.test.ts", + "e2e/tier2/us03-*.test.ts", + "e2e/tier2/us04-*.test.ts", + "e2e/tier2/us06-*.test.ts", + "e2e/tier2/us07-*.test.ts", + ], testTimeout: 2_100_000, hookTimeout: 2_100_000, }, @@ -63,10 +93,10 @@ export default defineConfig({ { // US-11 runs alone so the capacity cap reflects only its own // tickets. Invoked via a separate `vitest run --project` call - // after tier2-parallel finishes (see package.json scripts), so no + // after orchestration finishes (see package.json scripts), so no // other tier2 files hold Redis claim slots while it runs. test: { - name: "tier2-capacity", + name: "capacity", include: ["e2e/tier2/us11-*.test.ts"], testTimeout: 2_100_000, hookTimeout: 2_100_000, diff --git a/package.json b/package.json index 6e041d2..74dcbc8 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "test:e2e": "pnpm test:e2e:tier1 && pnpm test:e2e:tier2", + "test:e2e": "pnpm test:e2e:tier1 && pnpm test:e2e:agent && pnpm test:e2e:orchestration && pnpm test:e2e:capacity", "test:e2e:tier1": "vitest run --config e2e/vitest.e2e.config.ts --project tier1", - "test:e2e:tier2": "pnpm test:e2e:tier2:parallel && pnpm test:e2e:tier2:capacity", - "test:e2e:tier2:parallel": "vitest run --config e2e/vitest.e2e.config.ts --project tier2-parallel", - "test:e2e:tier2:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project tier2-capacity" + "test:e2e:agent": "vitest run --config e2e/vitest.e2e.config.ts --project agent", + "test:e2e:orchestration": "vitest run --config e2e/vitest.e2e.config.ts --project orchestration", + "test:e2e:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project capacity" }, "dependencies": { "@chat-adapter/slack": "^4.20.2", From c21ac119eadb4614c29260b4a68fe2ef023779ef Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 13:02:27 +0200 Subject: [PATCH 53/71] fix: gh actions --- .github/workflows/e2e.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e7ec705..b6ecb97 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,9 +29,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} - GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} - GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} @@ -75,9 +75,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} - GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} - GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} @@ -120,9 +120,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} - GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} - GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} @@ -165,9 +165,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} - GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} - GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} From 0605db9d13bfd6384d9f6c465f51c5b7bb2e3d57 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 13:33:43 +0200 Subject: [PATCH 54/71] feat: add oidc token to e2e --- .github/workflows/e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b6ecb97..b5011b0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,6 +36,7 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -82,6 +83,7 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -127,6 +129,7 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -172,6 +175,7 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 From 7be0bf0cc6b0c66b009263ffbd7044fdd80334f1 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 13:40:12 +0200 Subject: [PATCH 55/71] fix: us15 e2e --- .claude/learnings.md | 4 ++++ e2e/helpers/redis.ts | 6 +++++- e2e/tier2/us15-orphaned-run-cancelled.test.ts | 6 ++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.claude/learnings.md b/.claude/learnings.md index 5d3ba72..a6e1a3b 100644 --- a/.claude/learnings.md +++ b/.claude/learnings.md @@ -7,3 +7,7 @@ ## Sandbox push & branch creation - `@vercel/sandbox` git clones can be shallow by default, causing "no history in common with main" on PR creation when force-pushing from the sandbox. Always unshallow before pushing (`git fetch --unshallow origin`). - `GitHubAdapter.createBranch` must force-reset existing branches to the base SHA on 422, not silently return. Stale branches from previous failed runs can retain orphan history. + +## E2E in GitHub Actions +- `@vercel/sandbox` reads credentials from `process.env` — a GH secret is not enough; it must be mapped via the job's `env:` block (e.g. `VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }}`). Prefer long-lived `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` over OIDC — OIDC tokens expire in ~12h and the SDK's refresh path requires `.vercel/project.json`, which CI doesn't have. +- Reconcile (`src/lib/reconcile.ts`) has a 30s `ORPHAN_GRACE_MS` window that skips entries younger than 30s. Any e2e test seeding a registry entry via `setEntry` and expecting reconcile to cancel it on the next cron tick must backdate the timestamp past the grace window (`setEntry(key, runId, { ageMs: 60_000 })`). Without backdating the test is racy — it only passes if Vercel's 1-min scheduled cron happens to fire at T>30s during the test's wait window. diff --git a/e2e/helpers/redis.ts b/e2e/helpers/redis.ts index 8e4247e..83b5a43 100644 --- a/e2e/helpers/redis.ts +++ b/e2e/helpers/redis.ts @@ -30,12 +30,16 @@ export async function listAll(): Promise< export async function setEntry( ticketKey: string, runId: string, + opts?: { ageMs?: number }, ): Promise { await redis.hset(HASH_KEY, { [ticketKey]: runId }); // Mirror the production adapter: stamp a creation timestamp so // reconcile's orphan grace window (src/lib/reconcile.ts:ORPHAN_GRACE_MS) // treats the seeded entry as fresh, not as stale junk to clean up. - await redis.hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }); + // Callers exercising the orphan-cancel path (US-15) pass `ageMs` to + // backdate past the grace window so reconcile acts on the first tick. + const createdAt = Date.now() - (opts?.ageMs ?? 0); + await redis.hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(createdAt) }); } export async function cleanup(ticketKey: string): Promise { diff --git a/e2e/tier2/us15-orphaned-run-cancelled.test.ts b/e2e/tier2/us15-orphaned-run-cancelled.test.ts index 87f9451..d627d44 100644 --- a/e2e/tier2/us15-orphaned-run-cancelled.test.ts +++ b/e2e/tier2/us15-orphaned-run-cancelled.test.ts @@ -61,8 +61,10 @@ describe("US-15: Orphaned run cancelled when ticket leaves AI", () => { // 2. Seed a non-sentinel runId (a claiming: would trip the inflight // branch instead). This simulates the "workflow registered" state - // where the webhook-based cancel was missed. - await setEntry(ticketKey, SEEDED_RUN_ID); + // where the webhook-based cancel was missed. Backdate past + // reconcile's ORPHAN_GRACE_MS so the first cron tick acts on it + // instead of skipping the entry as a fresh orphan. + await setEntry(ticketKey, SEEDED_RUN_ID, { ageMs: 60_000 }); expect(await getRunId(ticketKey)).toBe(SEEDED_RUN_ID); // 3. Trigger the cron. Reconcile walks the registry, sees our ticket is From 0c8fc35a035add9c6c75d97a310e375cf335a04c Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 14:08:12 +0200 Subject: [PATCH 56/71] fix: us11 --- .github/workflows/e2e.yml | 4 + .../us11-capacity-limit-respected.test.ts | 75 ++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b5011b0..b5656d2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,6 +37,7 @@ jobs: AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -84,6 +85,7 @@ jobs: AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -130,6 +132,7 @@ jobs: AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -176,6 +179,7 @@ jobs: AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/e2e/tier2/us11-capacity-limit-respected.test.ts b/e2e/tier2/us11-capacity-limit-respected.test.ts index 6577142..ade173c 100644 --- a/e2e/tier2/us11-capacity-limit-respected.test.ts +++ b/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect, afterAll, beforeAll } from "vitest"; import { createTestTicket, moveTicketToColumn, getTicketStatus, deleteTicket, + isTicketVisibleInJql, } from "../helpers/jira.js"; import { findPR, closePR, deleteBranch } from "../helpers/github.js"; import { @@ -15,6 +16,7 @@ import { stopSandboxesForTicket, killClaudeForTicket, } from "../helpers/sandbox.js"; +import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; @@ -28,18 +30,39 @@ import { e2eEnv } from "../env.js"; * * Flow: * 1. Create MAX_CONCURRENT_AGENTS + 1 tickets in quick succession. - * 2. Move them all to AI. Each move fires a Jira webhook that calls - * dispatch; the first N claim Redis slots and start workflows, the - * (N+1)th hits the cap and is skipped. - * 3. Assert: exactly N claim entries exist in the registry for our + * 2. Move them all to AI, then wait for Jira's JQL index to reflect the + * transitions for every ticket. + * 3. Trigger a cron poll. Cron discovers all AI-column tickets and fires + * dispatch for each in parallel; the post-claim fairness check caps + * started workflows at MAX_CONCURRENT_AGENTS. + * 4. Assert: exactly N claim entries exist in the registry for our * ticket set, and the overflow ticket has no entry. * + * We drive dispatch from cron rather than webhooks because Jira webhook + * delivery is unreliable under parallel transitions (and absent in CI + * configurations without Jira admin access). Cron exercises the same + * `dispatchTicket` path. + * * Cleanup stops every sandbox and closes any PRs the N in-flight workflows * managed to open before we interrupted them. */ describe("US-11: Capacity limit respected", () => { const tickets: Array<{ ticketKey: string; branchName: string; prNumber?: number }> = []; + // Clear any stale registry entries left over from prior failed runs. + // Capacity is measured against the full registry, not just our tickets — + // leftovers silently consume slots and starve this test of claims. + beforeAll(async () => { + const stale = await listAllRuns(); + if (stale.length > 0) { + console.warn( + `[US-11] Clearing ${stale.length} stale registry entries before test:`, + stale.map((e) => e.ticketKey).join(", "), + ); + await Promise.all(stale.map((e) => redisCleanup(e.ticketKey))); + } + }); + afterAll(async () => { // Cancel running workflows FIRST by moving tickets out of AI. The Jira // webhook then sees "left AI" and calls cancelTrackedRun, which @@ -93,16 +116,46 @@ describe("US-11: Capacity limit respected", () => { tickets.push({ ticketKey, branchName: `blazebot/${ticketKey.toLowerCase()}` }); } - // 2. Move them all to AI in parallel. Jira fires a webhook per transition; - // each webhook triggers dispatch, which claims Redis via HSETNX. The - // registry-based capacity check rejects the overflow ticket. + // 2. Move them all to AI in parallel. await Promise.all( tickets.map((t) => moveTicketToColumn(t.ticketKey, e2eEnv.COLUMN_AI)), ); - // 3. Wait for exactly `max` of our tickets to be claimed. We poll the - // registry rather than the per-ticket entry because the Jira webhook - // ordering is not guaranteed — any `max` of the `total` can win. + // 3. Wait for Jira's JQL index to reflect the transitions for every + // ticket. Cron's `discoverAiColumnTickets` uses JQL, so we need every + // ticket visible before polling — otherwise cron dispatches a subset + // and the fairness cap will never produce exactly `max` claims. + await Promise.all( + tickets.map((t) => + waitFor( + async () => + (await isTicketVisibleInJql(t.ticketKey, e2eEnv.COLUMN_AI)) + ? true + : null, + { + description: `${t.ticketKey} visible in JQL under ${e2eEnv.COLUMN_AI}`, + timeoutMs: 60_000, + intervalMs: 2_000, + }, + ), + ), + ); + + // 4. Trigger cron. It fetches all AI-column tickets via JQL and calls + // dispatch for each in parallel; dispatch's post-claim fairness check + // caps started workflows at MAX_CONCURRENT_AGENTS. + const pollRes = await callCronPoll(); + console.log("[US-11] cron response:", JSON.stringify(pollRes.body)); + expect(pollRes.status).toBe(200); + // Sanity: cron saw all our tickets and dispatched the cap-limit count. + // If this fails, the real cause is visible in the logged response body + // (e.g. `discovered < total` → JQL still stale; `started < max` → + // capacity precheck saw a non-empty registry). + expect(pollRes.body?.discovered).toBeGreaterThanOrEqual(total); + expect(pollRes.body?.started).toBe(max); + + // 5. Wait for exactly `max` of our tickets to be claimed. Any `max` of + // the `total` can win under the fairness ordering. const ticketKeys = new Set(tickets.map((t) => t.ticketKey)); const claimed = await waitFor( async () => { From 8b876c5f871f35212f34a9828d971ff4edf28604 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 14:24:32 +0200 Subject: [PATCH 57/71] feat: new capacity check --- .../us11-capacity-limit-respected.test.ts | 232 +++++------------- 1 file changed, 63 insertions(+), 169 deletions(-) diff --git a/e2e/tier2/us11-capacity-limit-respected.test.ts b/e2e/tier2/us11-capacity-limit-respected.test.ts index ade173c..0d2f500 100644 --- a/e2e/tier2/us11-capacity-limit-respected.test.ts +++ b/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -2,20 +2,15 @@ import { describe, it, expect, afterAll, beforeAll } from "vitest"; import { createTestTicket, moveTicketToColumn, - getTicketStatus, deleteTicket, isTicketVisibleInJql, } from "../helpers/jira.js"; -import { findPR, closePR, deleteBranch } from "../helpers/github.js"; import { getRunId, + setEntry, listAll as listAllRuns, cleanup as redisCleanup, } from "../helpers/redis.js"; -import { - stopSandboxesForTicket, - killClaudeForTicket, -} from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; @@ -23,193 +18,92 @@ import { e2eEnv } from "../env.js"; /** * US-11: Capacity limit respected * - * Capacity is measured against the Redis active-runs registry, not against - * `Sandbox.list()` — a dispatched ticket is immediately counted, so the - * (N+1)th ticket in a batch reliably sees `at_capacity` on both the webhook - * and cron paths. - * - * Flow: - * 1. Create MAX_CONCURRENT_AGENTS + 1 tickets in quick succession. - * 2. Move them all to AI, then wait for Jira's JQL index to reflect the - * transitions for every ticket. - * 3. Trigger a cron poll. Cron discovers all AI-column tickets and fires - * dispatch for each in parallel; the post-claim fairness check caps - * started workflows at MAX_CONCURRENT_AGENTS. - * 4. Assert: exactly N claim entries exist in the registry for our - * ticket set, and the overflow ticket has no entry. + * Pre-saturates the Redis active-runs registry with MAX_CONCURRENT_AGENTS + * dummy entries so every capacity slot is already consumed. Then creates + * ONE real ticket and verifies dispatch rejects it — both cron and the + * deployed scheduled cron must see the registry as full and return + * `at_capacity` for the new ticket. * - * We drive dispatch from cron rather than webhooks because Jira webhook - * delivery is unreliable under parallel transitions (and absent in CI - * configurations without Jira admin access). Cron exercises the same - * `dispatchTicket` path. - * - * Cleanup stops every sandbox and closes any PRs the N in-flight workflows - * managed to open before we interrupted them. + * This replaces an older approach that created MAX+1 real tickets. That + * was correct but wasteful: with MAX=20 it spun up 20 real workflows and + * sandboxes just to prove the cap. Pre-saturating with dummies exercises + * the same `isAtCapacity` code path in `src/lib/dispatch.ts` without any + * real workflow execution. */ describe("US-11: Capacity limit respected", () => { - const tickets: Array<{ ticketKey: string; branchName: string; prNumber?: number }> = []; + const DUMMY_PREFIX = "E2E-DUMMY-US11-"; + const dummyKeys: string[] = []; + let ticketKey: string | null = null; - // Clear any stale registry entries left over from prior failed runs. - // Capacity is measured against the full registry, not just our tickets — - // leftovers silently consume slots and starve this test of claims. beforeAll(async () => { const stale = await listAllRuns(); if (stale.length > 0) { console.warn( - `[US-11] Clearing ${stale.length} stale registry entries before test:`, + `[US-11] Clearing ${stale.length} stale registry entries before saturation:`, stale.map((e) => e.ticketKey).join(", "), ); await Promise.all(stale.map((e) => redisCleanup(e.ticketKey))); } - }); - - afterAll(async () => { - // Cancel running workflows FIRST by moving tickets out of AI. The Jira - // webhook then sees "left AI" and calls cancelTrackedRun, which - // gracefully stops the workflow before any moveTicket step fires a - // 404 on a deleted Jira issue. - await Promise.all( - tickets.map(async (t) => { - try { - const status = await getTicketStatus(t.ticketKey); - if (status.toLowerCase() === e2eEnv.COLUMN_AI.toLowerCase()) { - await moveTicketToColumn(t.ticketKey, e2eEnv.COLUMN_BACKLOG); - } - } catch {} - }), - ); - - // Give the webhook-driven cancel path a moment to propagate before we - // start tearing down sandboxes and tickets out from under it. - await new Promise((r) => setTimeout(r, 5_000)); - for (const t of tickets) { - await stopSandboxesForTicket(t.ticketKey).catch(() => {}); - if (t.prNumber) await closePR(t.prNumber).catch(() => {}); - await deleteBranch(t.branchName).catch(() => {}); - await redisCleanup(t.ticketKey).catch(() => {}); - await deleteTicket(t.ticketKey).catch(() => {}); + // Seed MAX_CONCURRENT_AGENTS dummy entries with non-sentinel runIds. + // `isAtCapacity` counts every non-sentinel entry, so these fill every + // slot. Fresh timestamps (default `ageMs: 0`) keep reconcile's 30s + // orphan grace from wiping them mid-test. + for (let i = 0; i < e2eEnv.MAX_CONCURRENT_AGENTS; i++) { + const key = `${DUMMY_PREFIX}${i}`; + dummyKeys.push(key); + await setEntry(key, `run_e2e_dummy_${i}`); } + console.log( + `[US-11] Seeded ${dummyKeys.length} dummy entries to saturate capacity`, + ); }); - it("admits exactly MAX_CONCURRENT_AGENTS when more tickets arrive at once", async () => { - const max = e2eEnv.MAX_CONCURRENT_AGENTS; - const total = max + 1; - - // 1. Create N+1 tickets in parallel (all land in Backlog by default) - const created = await Promise.all( - Array.from({ length: total }, (_, i) => - createTestTicket({ - summary: `[E2E] Capacity batch ${i + 1}/${total}`, - description: [ - "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", - "", - "Acceptance criteria:", - "- Route file at app/api/health/route.ts", - "- Exports a GET handler", - '- Returns JSON response: { status: "ok" }', - ].join("\n"), - }), - ), - ); - for (const { ticketKey } of created) { - tickets.push({ ticketKey, branchName: `blazebot/${ticketKey.toLowerCase()}` }); + afterAll(async () => { + await Promise.all(dummyKeys.map((k) => redisCleanup(k).catch(() => {}))); + if (ticketKey) { + await redisCleanup(ticketKey).catch(() => {}); + await deleteTicket(ticketKey).catch(() => {}); } + }); - // 2. Move them all to AI in parallel. - await Promise.all( - tickets.map((t) => moveTicketToColumn(t.ticketKey, e2eEnv.COLUMN_AI)), - ); - - // 3. Wait for Jira's JQL index to reflect the transitions for every - // ticket. Cron's `discoverAiColumnTickets` uses JQL, so we need every - // ticket visible before polling — otherwise cron dispatches a subset - // and the fairness cap will never produce exactly `max` claims. - await Promise.all( - tickets.map((t) => - waitFor( - async () => - (await isTicketVisibleInJql(t.ticketKey, e2eEnv.COLUMN_AI)) - ? true - : null, - { - description: `${t.ticketKey} visible in JQL under ${e2eEnv.COLUMN_AI}`, - timeoutMs: 60_000, - intervalMs: 2_000, - }, - ), - ), - ); - - // 4. Trigger cron. It fetches all AI-column tickets via JQL and calls - // dispatch for each in parallel; dispatch's post-claim fairness check - // caps started workflows at MAX_CONCURRENT_AGENTS. - const pollRes = await callCronPoll(); - console.log("[US-11] cron response:", JSON.stringify(pollRes.body)); - expect(pollRes.status).toBe(200); - // Sanity: cron saw all our tickets and dispatched the cap-limit count. - // If this fails, the real cause is visible in the logged response body - // (e.g. `discovered < total` → JQL still stale; `started < max` → - // capacity precheck saw a non-empty registry). - expect(pollRes.body?.discovered).toBeGreaterThanOrEqual(total); - expect(pollRes.body?.started).toBe(max); - - // 5. Wait for exactly `max` of our tickets to be claimed. Any `max` of - // the `total` can win under the fairness ordering. - const ticketKeys = new Set(tickets.map((t) => t.ticketKey)); - const claimed = await waitFor( - async () => { - const all = await listAllRuns(); - const ours = all.filter((e) => ticketKeys.has(e.ticketKey)); - return ours.length === max ? ours : null; - }, + it("rejects a new ticket when every capacity slot is consumed", async () => { + // 1. Create a single real ticket and move it to AI. + const created = await createTestTicket({ + summary: "[E2E] Capacity overflow (should be rejected)", + description: + "Seeded dummies saturate capacity; dispatch must reject this ticket.", + }); + ticketKey = created.ticketKey; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // 2. Wait for JQL to reflect the transition — cron's + // discoverAiColumnTickets uses a JQL search, so the ticket must be + // indexed before polling. + await waitFor( + async () => + (await isTicketVisibleInJql(ticketKey!, e2eEnv.COLUMN_AI)) + ? true + : null, { - description: `${max} of ${total} tickets claimed (capacity limit)`, + description: `${ticketKey} visible in JQL under ${e2eEnv.COLUMN_AI}`, timeoutMs: 60_000, intervalMs: 2_000, }, ); - expect(claimed.length).toBe(max); - const claimedKeys = new Set(claimed.map((e) => e.ticketKey)); - const loserKeys = tickets.map((t) => t.ticketKey).filter((k) => !claimedKeys.has(k)); - expect(loserKeys.length).toBe(1); - - // 4. Hold the window for a few seconds to catch any late racing claim - // (e.g. a retry that would push us over cap). Value stays at `max`. - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - const all = await listAllRuns(); - const ours = all.filter((e) => ticketKeys.has(e.ticketKey)); - expect(ours.length).toBe(max); - await new Promise((r) => setTimeout(r, 2_000)); - } - - // 5. The losing ticket has no registry entry and no PR. - const [loserKey] = loserKeys; - expect(await getRunId(loserKey)).toBeNull(); - const loserBranch = `blazebot/${loserKey.toLowerCase()}`; - expect(await findPR(loserBranch)).toBeNull(); - - // 6. Capacity proven — kill claude in every winner sandbox so the - // test doesn't wait for full agent runs we don't care about. - // The workflow treats killed claude the same as US-7: sentinel - // with empty stdout → research `failed` → ticket moves to Backlog. - await Promise.all( - claimed.map(({ ticketKey }) => - waitFor(() => killClaudeForTicket(ticketKey), { - description: `kill claude in winner sandbox for ${ticketKey}`, - timeoutMs: 300_000, - intervalMs: 10_000, - }).catch(() => {}), - ), - ); + // 3. Trigger cron. Dispatch's `isAtCapacity` precheck sees MAX dummies + // and rejects our ticket before it can claim. (The deployed + // scheduled cron may also have fired during the JQL wait — it hits + // the same at-capacity rejection, so either way no claim lands.) + const pollRes = await callCronPoll(); + console.log("[US-11] cron response:", JSON.stringify(pollRes.body)); + expect(pollRes.status).toBe(200); + expect(pollRes.body?.discovered).toBeGreaterThanOrEqual(1); + expect(pollRes.body?.started).toBe(0); - // 7. Best-effort: capture any PRs the winners managed to open so cleanup - // can close them. - for (const t of tickets) { - const pr = await findPR(t.branchName).catch(() => null); - if (pr) t.prNumber = pr.number; - } + // 4. The ticket has no registry entry — capacity rejection confirmed. + expect(await getRunId(ticketKey)).toBeNull(); }); }); From 49727ff97b0f9282c835896562a89b95a7f2a0b6 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 14:35:38 +0200 Subject: [PATCH 58/71] feat: remove tier1 --- .github/workflows/e2e.yml | 50 +-------------------------------------- e2e/vitest.e2e.config.ts | 11 +-------- package.json | 3 +-- 3 files changed, 3 insertions(+), 61 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b5656d2..36dd139 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,56 +14,8 @@ on: default: all jobs: - e2e-tier1: - if: inputs.tier == 'all' - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: e2e - env: - E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} - JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} - JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} - COLUMN_AI: ${{ secrets.COLUMN_AI }} - COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} - COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} - E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} - E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} - CRON_SECRET: ${{ secrets.CRON_SECRET }} - AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} - AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} - MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: "20.12" - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run test:e2e:tier1 - - if: failure() - uses: actions/upload-artifact@v4 - with: - name: e2e-tier1-results - path: | - e2e/**/*.log - retention-days: 7 - e2e-agent: - needs: [e2e-tier1] - # Runs when picked alone (tier1 skipped) or as part of `all`. - if: | - always() && - (needs.e2e-tier1.result == 'success' || needs.e2e-tier1.result == 'skipped') && - (inputs.tier == 'agent' || inputs.tier == 'all') + if: inputs.tier == 'agent' || inputs.tier == 'all' runs-on: ubuntu-latest timeout-minutes: 120 environment: e2e diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 001b53b..2bc3662 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -32,20 +32,11 @@ export default defineConfig({ sequence: { concurrent: false }, // Enable cross-file parallelism; `maxWorkers` caps how many files run // simultaneously. The `agent` and `orchestration` projects rely on - // this; `capacity` has a single file, so this flag is a no-op for it; - // tier1 is currently empty. + // this; `capacity` has a single file, so this flag is a no-op for it. fileParallelism: true, maxWorkers: 6, minWorkers: 1, projects: [ - { - test: { - name: "tier1", - include: ["e2e/tier1/**/*.test.ts"], - testTimeout: 120_000, - hookTimeout: 120_000, - }, - }, { // Agent tests — provision real sandboxes and run Claude Code. // Run these in parallel FIRST so expensive failures surface early diff --git a/package.json b/package.json index 74dcbc8..208f9cc 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "test:e2e": "pnpm test:e2e:tier1 && pnpm test:e2e:agent && pnpm test:e2e:orchestration && pnpm test:e2e:capacity", - "test:e2e:tier1": "vitest run --config e2e/vitest.e2e.config.ts --project tier1", + "test:e2e": "pnpm test:e2e:agent && pnpm test:e2e:orchestration && pnpm test:e2e:capacity", "test:e2e:agent": "vitest run --config e2e/vitest.e2e.config.ts --project agent", "test:e2e:orchestration": "vitest run --config e2e/vitest.e2e.config.ts --project orchestration", "test:e2e:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project capacity" From 9b10c4611a3ebff3a5890ffda4e8f4400c143282 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 14:46:05 +0200 Subject: [PATCH 59/71] feat: remove duplicates --- .github/workflows/e2e.yml | 8 +- e2e/tier2/us1-clear-ticket-pr.test.ts | 108 ---------- e2e/tier2/us2-attachments.test.ts | 189 ------------------ e2e/tier2/us3-review-fix-cycle.test.ts | 166 --------------- e2e/tier2/us4-merge-conflict-rebase.test.ts | 173 ---------------- .../us5-unclear-ticket-clarification.test.ts | 100 --------- e2e/tier2/us6-clarification-answered.test.ts | 167 ---------------- e2e/tier2/us7-agent-failure-backlog.test.ts | 103 ---------- e2e/tier2/us8-previously-failed-skip.test.ts | 92 --------- e2e/tier2/us9-failed-marker-cleared.test.ts | 78 -------- 10 files changed, 5 insertions(+), 1179 deletions(-) delete mode 100644 e2e/tier2/us1-clear-ticket-pr.test.ts delete mode 100644 e2e/tier2/us2-attachments.test.ts delete mode 100644 e2e/tier2/us3-review-fix-cycle.test.ts delete mode 100644 e2e/tier2/us4-merge-conflict-rebase.test.ts delete mode 100644 e2e/tier2/us5-unclear-ticket-clarification.test.ts delete mode 100644 e2e/tier2/us6-clarification-answered.test.ts delete mode 100644 e2e/tier2/us7-agent-failure-backlog.test.ts delete mode 100644 e2e/tier2/us8-previously-failed-skip.test.ts delete mode 100644 e2e/tier2/us9-failed-marker-cleared.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 36dd139..f8e88f3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,6 +1,8 @@ name: E2E Tests on: + push: + branches: [main, dev] workflow_dispatch: inputs: tier: @@ -15,7 +17,7 @@ on: jobs: e2e-agent: - if: inputs.tier == 'agent' || inputs.tier == 'all' + if: github.event_name == 'push' || inputs.tier == 'agent' || inputs.tier == 'all' runs-on: ubuntu-latest timeout-minutes: 120 environment: e2e @@ -62,7 +64,7 @@ jobs: if: | always() && (needs.e2e-agent.result == 'success' || needs.e2e-agent.result == 'skipped') && - (inputs.tier == 'orchestration' || inputs.tier == 'all') + (github.event_name == 'push' || inputs.tier == 'orchestration' || inputs.tier == 'all') runs-on: ubuntu-latest timeout-minutes: 60 environment: e2e @@ -109,7 +111,7 @@ jobs: if: | always() && (needs.e2e-orchestration.result == 'success' || needs.e2e-orchestration.result == 'skipped') && - (inputs.tier == 'capacity' || inputs.tier == 'all') + (github.event_name == 'push' || inputs.tier == 'capacity' || inputs.tier == 'all') runs-on: ubuntu-latest timeout-minutes: 30 environment: e2e diff --git a/e2e/tier2/us1-clear-ticket-pr.test.ts b/e2e/tier2/us1-clear-ticket-pr.test.ts deleted file mode 100644 index 087e21e..0000000 --- a/e2e/tier2/us1-clear-ticket-pr.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { - findPR, - getPRCommits, - getPRFiles, - getFileContent, - closePR, - deleteBranch, -} from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-1: Clear ticket produces a PR [GitHub] - * - * When a ticket with clear requirements is moved to the AI column, - * the agent implements the feature and creates a PR for review. - */ -describe("US-1: Clear ticket produces a PR", () => { - let ticketKey: string; - let branchName: string; - let prNumber: number | undefined; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (prNumber) await closePR(prNumber); - if (branchName) await deleteBranch(branchName); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("implements a clear ticket and creates a PR on the correct branch", async () => { - // 1. Create ticket with very specific requirements so we can validate the output - const ticket = await createTestTicket({ - summary: "[E2E] Add GET /api/health endpoint", - description: [ - "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", - "", - "Acceptance criteria:", - '- Route file at app/api/health/route.ts', - '- Exports a GET handler', - '- Returns JSON response: { status: "ok" }', - "- HTTP 200 response", - "- No other files created or modified", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // 2. Move to AI column — webhook or cron triggers dispatch - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - await callCronPoll(); - - // 3. Wait for PR to appear on the expected branch - const pr = await waitFor(() => findPR(branchName), { - description: `PR on branch ${branchName}`, - timeoutMs: 2_000_000, - }); - prNumber = pr.number; - - // 4. PR has at least 1 commit - const commits = await getPRCommits(prNumber); - expect(commits.length).toBeGreaterThan(0); - - // 5. PR contains the health route file - const prFiles = await getPRFiles(prNumber); - const filenames = prFiles.map((f) => f.filename); - expect(filenames.some((f) => f.includes("health/route"))).toBe(true); - - // 6. Route file exports a GET handler and returns { status: "ok" } - const routeContent = await getFileContent( - branchName, - "app/api/health/route.ts", - ); - expect(routeContent).not.toBeNull(); - expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); - expect(routeContent).toContain('"ok"'); - - // 7. Ticket moved to AI Review - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_AI_REVIEW}`, timeoutMs: 60_000 }, - ); - - // 8. Redis entry cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, - ); - }); -}); diff --git a/e2e/tier2/us2-attachments.test.ts b/e2e/tier2/us2-attachments.test.ts deleted file mode 100644 index 8484e2b..0000000 --- a/e2e/tier2/us2-attachments.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - addAttachment, - deleteTicket, -} from "../helpers/jira.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-2: Ticket with attachments — real fetch + write pipeline - * - * Creates a ticket in Backlog with various attachment types (PNG, JSON, PDF, - * TXT, MD), then uses the production JiraAdapter + fetchAttachmentsWithRetry - * + sandbox writeFiles pipeline to verify the full attachment flow end-to-end. - */ -describe("US-2: Ticket with attachments (real pipeline)", () => { - let ticketKey: string; - let sandbox: { stop: () => Promise } | undefined; - - afterAll(async () => { - if (sandbox) await sandbox.stop().catch(() => {}); - if (ticketKey) await deleteTicket(ticketKey); - }); - - it("fetches attachments via JiraAdapter and writes them to a sandbox", async () => { - // 1. Create a ticket (stays in Backlog — no workflow triggered) - const ticket = await createTestTicket({ - summary: "[E2E] Create user profile card component", - description: - "Build a profile card component matching the attached mockup and specs.", - }); - ticketKey = ticket.ticketKey; - - // 2. Upload test attachments of various types to Jira - const mockupContent = Buffer.alloc(1024, 0x89); // 1 KB binary placeholder - const tokensContent = Buffer.from( - JSON.stringify({ primary: "#FF6B35", spacing: "16px" }), - ); - const pdfContent = Buffer.from( - "%PDF-1.4\n1 0 obj<>endobj\n%%EOF\n", - ); - const txtContent = Buffer.from( - "Profile card should be 320px wide with 16px padding on all sides.\n", - ); - const mdContent = Buffer.from( - [ - "# Profile Card Spec", - "", - "## Requirements", - "- Avatar: 64x64 circle", - "- Name: 18px bold", - "- Role: 14px muted", - "", - ].join("\n"), - ); - - await addAttachment(ticketKey, "profile-mockup.png", mockupContent); - await addAttachment(ticketKey, "design-tokens.json", tokensContent); - await addAttachment(ticketKey, "wireframe.pdf", pdfContent); - await addAttachment(ticketKey, "sizing-notes.txt", txtContent); - await addAttachment(ticketKey, "spec.md", mdContent); - - // 3. Use the real JiraAdapter to fetch the ticket (like the workflow does) - const { JiraAdapter } = await import( - "../../src/adapters/issue-tracker/jira.js" - ); - const jira = new JiraAdapter({ - baseUrl: e2eEnv.JIRA_BASE_URL, - email: e2eEnv.JIRA_EMAIL, - apiToken: e2eEnv.JIRA_API_TOKEN, - projectKey: e2eEnv.JIRA_PROJECT_KEY, - }); - - const ticketData = await jira.fetchTicket(ticketKey); - expect(ticketData.attachments).toHaveLength(5); - - // 4. Use the real fetchAttachmentsWithRetry to download (like the workflow does) - const { fetchAttachmentsWithRetry } = await import( - "../../src/sandbox/attachments.js" - ); - const log = { - info: () => {}, - warn: () => {}, - }; - - const downloaded = await fetchAttachmentsWithRetry( - jira, - ticketData.attachments, - { - maxFileSizeBytes: 10 * 1024 * 1024, - maxTotalSizeBytes: 50 * 1024 * 1024, - maxCount: 20, - downloadTimeoutMs: 30_000, - }, - log, - ); - - // All 5 attachments downloaded successfully - const succeeded = downloaded.filter((a) => !a.failed); - expect(succeeded).toHaveLength(5); - for (const a of succeeded) { - expect(a.content).toBeDefined(); - expect(a.content!.length).toBeGreaterThan(0); - } - - // 5. Create a sandbox and write files using the same pattern as the workflow - const { Sandbox } = await import("@vercel/sandbox"); - const sbx = await Sandbox.create({ - source: { - type: "git", - url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`, - username: "x-access-token", - password: e2eEnv.E2E_GITHUB_TOKEN, - revision: "main", - depth: 1, - }, - runtime: "node24", - timeout: 120_000, - }); - sandbox = sbx; - - // Write files the same way the real writeAttachments step does - const toWrite = succeeded.filter((a) => a.content); - await sbx.runCommand("mkdir", ["-p", "/tmp/attachments"]); - await sbx.writeFiles( - toWrite.map((a) => ({ - path: `/tmp/attachments/${a.filename}`, - content: Buffer.isBuffer(a.content) - ? a.content - : Buffer.from(a.content as unknown as Uint8Array), - })), - ); - - // 6. Verify: all files exist at expected paths - for (const a of toWrite) { - const result = await sbx.runCommand("test", [ - "-f", - `/tmp/attachments/${a.filename}`, - ]); - expect(result.exitCode).toBe(0); - } - - // 7. Verify: binary file (PNG) preserved exact size - const pngFile = toWrite.find((a) => a.originalFilename === "profile-mockup.png")!; - const pngStat = await sbx.runCommand("wc", [ - "-c", - `/tmp/attachments/${pngFile.filename}`, - ]); - const pngSize = parseInt( - (await pngStat.stdout()).trim().split(/\s+/)[0], - 10, - ); - expect(pngSize).toBe(mockupContent.length); - - // 8. Verify: JSON file is valid and has correct content - const jsonFile = toWrite.find((a) => a.originalFilename === "design-tokens.json")!; - const jsonResult = await sbx.runCommand("cat", [ - `/tmp/attachments/${jsonFile.filename}`, - ]); - const jsonContent = (await jsonResult.stdout()).trim(); - expect(() => JSON.parse(jsonContent)).not.toThrow(); - expect(JSON.parse(jsonContent).primary).toBe("#FF6B35"); - - // 9. Verify: PDF file starts with PDF header - const pdfFile = toWrite.find((a) => a.originalFilename === "wireframe.pdf")!; - const pdfResult = await sbx.runCommand("head", [ - "-c", - "5", - `/tmp/attachments/${pdfFile.filename}`, - ]); - expect((await pdfResult.stdout()).trim()).toBe("%PDF-"); - - // 10. Verify: TXT file content matches - const txtFile = toWrite.find((a) => a.originalFilename === "sizing-notes.txt")!; - const txtResult = await sbx.runCommand("cat", [ - `/tmp/attachments/${txtFile.filename}`, - ]); - expect((await txtResult.stdout()).trim()).toContain("320px wide"); - - // 11. Verify: MD file content matches - const mdFile = toWrite.find((a) => a.originalFilename === "spec.md")!; - const mdResult = await sbx.runCommand("cat", [ - `/tmp/attachments/${mdFile.filename}`, - ]); - const mdOutput = (await mdResult.stdout()).trim(); - expect(mdOutput).toContain("# Profile Card Spec"); - expect(mdOutput).toContain("64x64 circle"); - }); -}); diff --git a/e2e/tier2/us3-review-fix-cycle.test.ts b/e2e/tier2/us3-review-fix-cycle.test.ts deleted file mode 100644 index dd131f8..0000000 --- a/e2e/tier2/us3-review-fix-cycle.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { - createBranch, - createOrUpdateFile, - openPR, - findPR, - getPRCommits, - getPRFiles, - getFileContent, - addPRComment, - closePR, - deleteBranch, -} from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-3: Review feedback triggers a fix cycle [GitHub] - * - * When a developer leaves review comments on the agent's PR and moves - * the ticket back to AI, the agent addresses the feedback and pushes - * updates to the same PR — no duplicate PR created. - * - * Setup uses GitHub API to create branch + code + PR in seconds, - * instead of waiting for a full workflow run. - */ -describe("US-3: Review feedback triggers a fix cycle", () => { - let ticketKey: string; - let branchName: string; - let prNumber: number | undefined; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (prNumber) await closePR(prNumber); - if (branchName) await deleteBranch(branchName); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("addresses review comments and pushes updates to the same PR", async () => { - // --- Setup: create ticket + branch + initial code + PR via GitHub API --- - - const ticket = await createTestTicket({ - summary: "[E2E] Add GET /api/ping endpoint", - description: [ - "Add a GET /api/ping API route that returns JSON { ping: 'pong' } with status 200.", - "", - "Acceptance criteria:", - "- Route file at app/api/ping/route.ts", - "- Exports a GET handler function", - '- Returns JSON response: { ping: "pong" }', - "- HTTP 200 response", - "- No other files created or modified", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // Create branch with a simple implementation - await createBranch(branchName); - await createOrUpdateFile( - branchName, - "app/api/ping/route.ts", - [ - 'import { NextResponse } from "next/server";', - "", - "export async function GET() {", - ' return NextResponse.json({ ping: "pong" });', - "}", - "", - ].join("\n"), - "feat: add GET /api/ping endpoint", - ); - - // Create PR and record initial commit count - const pr = await openPR( - branchName, - `[${ticketKey}] Add GET /api/ping endpoint`, - ); - prNumber = pr.number; - - const commitsBefore = await getPRCommits(prNumber); - const commitCountBefore = commitsBefore.length; - - // Add a review comment requesting a rename with specific instructions - await addPRComment( - prNumber, - [ - "Please make these changes:", - "1. Delete app/api/ping/route.ts entirely", - "2. Create app/api/healthcheck/route.ts instead", - '3. The new route must export a GET handler that returns JSON { healthcheck: "passed" }', - "4. No other files should be created or modified", - ].join("\n"), - ); - - // --- Act: move ticket to AI to trigger the review-fix workflow --- - - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // Poke cron to ensure dispatch if webhook didn't fire - await callCronPoll(); - - // --- Assert --- - - // Ticket moves to AI Review (workflow completed) - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { - description: `ticket → ${e2eEnv.COLUMN_AI_REVIEW} after review-fix`, - timeoutMs: 2_000_000, - }, - ); - - // PR has more commits than before the review fix - const commitsAfter = await getPRCommits(prNumber); - expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); - - // No duplicate PR — same PR number is still the only open PR for this branch - const currentPR = await findPR(branchName); - expect(currentPR).not.toBeNull(); - expect(currentPR!.number).toBe(prNumber); - - // Old /ping route removed, /healthcheck exists (check PR aggregate diff) - const prFiles = await getPRFiles(prNumber); - const filenames = prFiles.map((f) => f.filename); - expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); - expect(filenames.some((f) => f.includes("/ping/"))).toBe(false); - - // Healthcheck route file exists on the branch with correct content - const routeContent = await getFileContent( - branchName, - "app/api/healthcheck/route.ts", - ); - expect(routeContent).not.toBeNull(); - expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); - expect(routeContent).toContain('"passed"'); - - // Old ping route must not exist on the branch - const oldRoute = await getFileContent(branchName, "app/api/ping/route.ts"); - expect(oldRoute).toBeNull(); - - // Redis cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, - ); - }); -}); diff --git a/e2e/tier2/us4-merge-conflict-rebase.test.ts b/e2e/tier2/us4-merge-conflict-rebase.test.ts deleted file mode 100644 index 6c358d4..0000000 --- a/e2e/tier2/us4-merge-conflict-rebase.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { - createBranch, - createOrUpdateFile, - openPR, - getPRCommits, - getFileContent, - isPRMergeable, - closePR, - deleteBranch, - deleteFile, -} from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-4: PR with merge conflicts — agent rebases [GitHub] - * - * When a ticket's PR has merge conflicts with main, moving the ticket - * back to AI triggers the agent to resolve the conflicts. The sandbox - * is provisioned with `mergeBase` so the agent can see and fix them. - * - * Setup uses GitHub API to create a branch, add a file, add a - * CONFLICTING file on main, then create a PR that shows conflicts. - */ -describe("US-4: PR with merge conflicts — agent rebases", () => { - const uniqueDir = `blazebot-e2e-${Date.now()}`; - const conflictFile = `${uniqueDir}/data.txt`; - let ticketKey: string; - let branchName: string; - let prNumber: number | undefined; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (prNumber) await closePR(prNumber); - if (branchName) await deleteBranch(branchName); - await deleteFile( - "main", - conflictFile, - "[E2E] cleanup conflict test file", - ).catch(() => {}); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("resolves merge conflicts and pushes an updated branch", async () => { - // --- Setup: create a PR that has merge conflicts --- - - const ticket = await createTestTicket({ - summary: `[E2E] Add greeting file at ${conflictFile}`, - description: [ - `Create a file at ${conflictFile} with a single line containing exactly: Hello from blazebot`, - "", - "Acceptance criteria:", - `- File exists at path ${conflictFile}`, - "- File contains exactly one line: Hello from blazebot", - "- No other text or content in the file", - "- No other files created or modified", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // Create branch and add the file with the correct content - await createBranch(branchName); - await createOrUpdateFile( - branchName, - conflictFile, - "Hello from blazebot\n", - "feat: add greeting file", - ); - - // Create a CONFLICTING version of the same file on main - await createOrUpdateFile( - "main", - conflictFile, - "This space is reserved\n", - "[E2E] create conflict baseline on main", - ); - - // Create PR — will have merge conflicts since both sides added the same file - const pr = await openPR( - branchName, - `[${ticketKey}] Add greeting file`, - ); - prNumber = pr.number; - - // Wait for GitHub to detect the merge conflict - await waitFor( - async () => { - const mergeable = await isPRMergeable(prNumber!); - return mergeable === false ? true : null; - }, - { - description: `PR #${prNumber} detected as conflicting`, - timeoutMs: 30_000, - intervalMs: 3_000, - }, - ); - - const commitsBefore = await getPRCommits(prNumber); - const commitCountBefore = commitsBefore.length; - - // --- Act: move ticket to AI to trigger conflict resolution --- - - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // Poke cron to ensure dispatch if webhook didn't fire - await callCronPoll(); - - // --- Assert --- - - // Ticket moves to AI Review (workflow completed successfully) - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { - description: `ticket → ${e2eEnv.COLUMN_AI_REVIEW} after conflict resolution`, - timeoutMs: 2_000_000, - }, - ); - - // PR no longer has merge conflicts - await waitFor( - async () => { - const mergeable = await isPRMergeable(prNumber!); - return mergeable === true ? true : null; - }, - { - description: `PR #${prNumber} is now mergeable`, - timeoutMs: 30_000, - intervalMs: 3_000, - }, - ); - - // PR has new commits (conflict resolution commit) - const commitsAfter = await getPRCommits(prNumber); - expect(commitsAfter.length).toBeGreaterThan(commitCountBefore); - - // Conflict file on the branch contains the ticket's expected content - const fileContent = await getFileContent(branchName, conflictFile); - expect(fileContent).not.toBeNull(); - expect(fileContent!.trim()).toContain("Hello from blazebot"); - // Must not contain conflict markers - expect(fileContent).not.toMatch(/^<{7}/m); - - // Ticket status is AI Review - const finalStatus = await getTicketStatus(ticketKey); - expect(finalStatus).toBe(e2eEnv.COLUMN_AI_REVIEW); - - // Redis cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, - ); - }); -}); diff --git a/e2e/tier2/us5-unclear-ticket-clarification.test.ts b/e2e/tier2/us5-unclear-ticket-clarification.test.ts deleted file mode 100644 index 735cd4d..0000000 --- a/e2e/tier2/us5-unclear-ticket-clarification.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - getTicketComments, - deleteTicket, -} from "../helpers/jira.js"; -import { findPR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-5: Unclear ticket triggers clarification - * - * When a ticket is too vague/subjective to implement, the agent should - * return STATUS: clarification_needed, post numbered questions as a Jira - * comment, move the ticket to Backlog, and clean up Redis/sandbox. - */ -describe("US-5: Unclear ticket triggers clarification", () => { - let ticketKey: string; - let branchName: string; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (branchName) await deleteBranch(branchName).catch(() => {}); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("asks clarification questions and moves the ticket to Backlog", async () => { - // 1. Create a deliberately vague ticket — subjective reference with - // no explicit target. This is exactly what the research prompt's - // clarity gate is designed to catch. - const ticket = await createTestTicket({ - summary: "[E2E] Change website color to my favorite color", - description: [ - "Update the primary brand color across the site to my favorite color.", - "", - "My favorite color is not specified anywhere in this ticket.", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // 2. Move to AI column — webhook or cron triggers dispatch - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - await callCronPoll(); - - // 3. Wait for the ticket to land in Backlog — the research phase is - // the only phase that runs in this path, so this is much faster - // than a full implementation. - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_BACKLOG ? status : null; - }, - { - description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG}`, - timeoutMs: 1_500_000, - }, - ); - - // 4. A Jira comment with numbered questions must have been posted. - // The workflow formats questions as "1. ...\n2. ..." via - // postClarificationAndMoveBack. - const comments = await getTicketComments(ticketKey); - const clarificationComment = comments.find((c) => - /^\s*1\.\s/m.test(c.body), - ); - expect(clarificationComment).toBeDefined(); - expect(clarificationComment!.body).toMatch(/^\s*1\.\s/m); - - // 5. No PR was created — clarification halts before implementation - const pr = await findPR(branchName); - expect(pr).toBeNull(); - - // 6. Redis entry cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, - ); - - // 7. No sandbox still running for this ticket - const stopped = await stopSandboxesForTicket(ticketKey); - expect(stopped).toBe(0); - - // 8. Final status assertion - const finalStatus = await getTicketStatus(ticketKey); - expect(finalStatus).toBe(e2eEnv.COLUMN_BACKLOG); - }); -}); diff --git a/e2e/tier2/us6-clarification-answered.test.ts b/e2e/tier2/us6-clarification-answered.test.ts deleted file mode 100644 index ee4eed0..0000000 --- a/e2e/tier2/us6-clarification-answered.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - getTicketComments, - postComment, - deleteTicket, -} from "../helpers/jira.js"; -import { - findPR, - getPRCommits, - getFileContent, - closePR, - deleteBranch, -} from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-6: Clarification answered — ticket re-processed successfully [GitHub] - * - * After the agent asks a clarification question and moves the ticket to - * Backlog, the developer posts an answer as a Jira comment and moves the - * ticket back to AI. The research phase then reads the comment, the - * clarity gate passes, and the implementation proceeds to a PR. - * - * This covers two full workflow runs in sequence, so the per-test timeout - * is larger than the project default. - */ -describe("US-6: Clarification answered → ticket completes", () => { - // Unique value so the PR content check can't pass on pre-existing files - const uniqueGreeting = `Hello from Blazebot US-6 ${Date.now()}`; - let ticketKey: string; - let branchName: string; - let prNumber: number | undefined; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (prNumber) await closePR(prNumber); - if (branchName) await deleteBranch(branchName).catch(() => {}); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it( - "uses the developer's answer from comments and implements the ticket", - async () => { - // --- Phase A: trigger clarification with a ticket missing a value --- - - const ticket = await createTestTicket({ - summary: "[E2E] Add greeting endpoint with my favorite greeting", - description: [ - "Create a GET /api/greeting route at app/api/greeting/route.ts", - "that returns JSON { message: X } with HTTP 200.", - "", - "The value of X is my favorite greeting. It is not specified in", - "this ticket — I will provide it in a follow-up comment.", - "", - "Acceptance criteria:", - "- Route file at app/api/greeting/route.ts", - "- Exports a GET handler", - "- Returns JSON: { message: X } where X is my favorite greeting", - "- HTTP 200 response", - "- No other files created or modified", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - await callCronPoll(); - - // Wait for Backlog (clarification path) — research-only, so fast - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_BACKLOG ? status : null; - }, - { - description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG} (clarification)`, - timeoutMs: 1_500_000, - }, - ); - - // Clarification comment must exist before we answer - const preAnswerComments = await getTicketComments(ticketKey); - const clarificationComment = preAnswerComments.find((c) => - /^\s*1\.\s/m.test(c.body), - ); - expect(clarificationComment).toBeDefined(); - - // Redis cleaned up after clarification before we restart - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { - description: `Redis clean after clarification for ${ticketKey}`, - timeoutMs: 30_000, - }, - ); - - // --- Phase B: developer answers + moves back to AI --- - - await postComment( - ticketKey, - `1. Use "${uniqueGreeting}" as the message value.`, - ); - - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - await callCronPoll(); - - // Wait for AI Review — this time the full workflow runs - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_AI_REVIEW ? status : null; - }, - { - description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_AI_REVIEW} after answer`, - timeoutMs: 2_000_000, - }, - ); - - // --- Assert: PR created with the answered value --- - - const pr = await waitFor(() => findPR(branchName), { - description: `PR on branch ${branchName}`, - timeoutMs: 60_000, - }); - prNumber = pr.number; - - const commits = await getPRCommits(prNumber); - expect(commits.length).toBeGreaterThan(0); - - // The route file must exist on the branch and contain the answered - // greeting verbatim — proof the agent used the comment, not a guess. - const routeContent = await getFileContent( - branchName, - "app/api/greeting/route.ts", - ); - expect(routeContent).not.toBeNull(); - expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); - expect(routeContent).toContain(uniqueGreeting); - - // Redis cleaned up after the implementation run - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { - description: `Redis clean after implementation for ${ticketKey}`, - timeoutMs: 30_000, - }, - ); - }, - 4_200_000, // 70 min — two workflow runs back-to-back - ); -}); diff --git a/e2e/tier2/us7-agent-failure-backlog.test.ts b/e2e/tier2/us7-agent-failure-backlog.test.ts deleted file mode 100644 index cee00f2..0000000 --- a/e2e/tier2/us7-agent-failure-backlog.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { findPR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; -import { - stopSandboxesForTicket, - killClaudeForTicket, -} from "../helpers/sandbox.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-7: Agent failure moves ticket to Backlog - * - * When the agent fails mid-run, the ticket should move to Backlog and all - * resources (Redis entry, sandbox) should be cleaned up. We simulate the - * failure by killing the claude process inside the research-phase sandbox — - * the wrapper script's cleanup still touches the sentinel, and - * parseResearchStatus defaults to `failed` on empty/partial stdout. - */ -describe("US-7: Agent failure moves ticket to Backlog", () => { - let ticketKey: string; - let branchName: string; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (branchName) await deleteBranch(branchName).catch(() => {}); - if (ticketKey) { - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("moves ticket to Backlog and cleans up when the agent fails", async () => { - // 1. Create a normal, clear ticket — would succeed on the happy path - const ticket = await createTestTicket({ - summary: "[E2E] Add GET /api/health endpoint", - description: [ - "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", - "", - "Acceptance criteria:", - "- Route file at app/api/health/route.ts", - "- Exports a GET handler", - '- Returns JSON response: { status: "ok" }', - "- HTTP 200 response", - ].join("\n"), - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // 2. Move to AI column — Jira webhook triggers dispatch - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // 3. Poll until the research-phase sandbox exists, then kill claude. - // killClaudeForTicket returns true once it finds and pkills the - // claude process in the sandbox matching this ticket's branch. - await waitFor(() => killClaudeForTicket(ticketKey), { - description: `sandbox ready to kill for ${ticketKey}`, - timeoutMs: 300_000, - intervalMs: 10_000, - }); - - // 4. Workflow's pollUntilDone picks up the sentinel within 30s, - // collectPhaseOutput reads empty stdout, parseResearchStatus - // defaults to `failed`, workflow moves ticket to Backlog. - await waitFor( - async () => { - const status = await getTicketStatus(ticketKey); - return status === e2eEnv.COLUMN_BACKLOG ? status : null; - }, - { - description: `ticket ${ticketKey} → ${e2eEnv.COLUMN_BACKLOG}`, - timeoutMs: 300_000, - }, - ); - - // 5. No PR was created — failure halts before push - const pr = await findPR(branchName); - expect(pr).toBeNull(); - - // 6. Redis entry cleaned up - await waitFor( - async () => { - const runId = await getRunId(ticketKey); - return runId === null ? true : null; - }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 60_000 }, - ); - - // 7. No sandbox still running for this ticket - const stopped = await stopSandboxesForTicket(ticketKey); - expect(stopped).toBe(0); - - // 8. Final status assertion - const finalStatus = await getTicketStatus(ticketKey); - expect(finalStatus).toBe(e2eEnv.COLUMN_BACKLOG); - }); -}); diff --git a/e2e/tier2/us8-previously-failed-skip.test.ts b/e2e/tier2/us8-previously-failed-skip.test.ts deleted file mode 100644 index 61d0ddc..0000000 --- a/e2e/tier2/us8-previously-failed-skip.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { findPR, deleteBranch } from "../helpers/github.js"; -import { - getRunId, - cleanup as redisCleanup, - markFailed, - isTicketFailed, - cleanupFailed, -} from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-8: Previously-failed ticket is skipped on re-poll - * - * A ticket with a Redis failure marker must not be re-dispatched even while - * it sits in the AI column — the dispatch precheck returns - * `previously_failed` and no workflow is started. - * - * We seed the failure marker directly because its only production trigger is - * the workflow's catch-block safeguard (Jira unreachable during error - * recovery), which is impractical to force in e2e. - */ -describe("US-8: Previously-failed ticket is skipped", () => { - let ticketKey: string; - let branchName: string; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (branchName) await deleteBranch(branchName).catch(() => {}); - if (ticketKey) { - await cleanupFailed(ticketKey); - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("does not dispatch a workflow for a ticket marked failed", async () => { - // 1. Create a clear ticket — would succeed if dispatched - const ticket = await createTestTicket({ - summary: "[E2E] Previously-failed skip guard", - description: "Clear ticket; this test verifies it is NOT dispatched.", - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - // 2. Seed the failure marker in Redis (simulates the catch-block safeguard) - await markFailed(ticketKey, { - runId: "run_e2e_seeded", - error: "seeded by e2e test", - failedAt: new Date().toISOString(), - }); - - // 3. Move to AI column — Jira webhook triggers dispatch, which must skip - // because the failure marker is present. - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); - - // 4. Give the webhook + dispatch precheck time to run, then assert that - // no active-run Redis entry was ever created. We poll for the full - // window rather than a single check to catch any claim that might - // appear mid-window (e.g. from a retry). - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - const runId = await getRunId(ticketKey); - expect(runId).toBeNull(); - await new Promise((r) => setTimeout(r, 2_000)); - } - - // 5. Failure marker still present — reconcile only clears it when the - // ticket has *left* the AI column (US-9 covers that path) - expect(await isTicketFailed(ticketKey)).toBe(true); - - // 6. No PR and no sandbox for this ticket - const pr = await findPR(branchName); - expect(pr).toBeNull(); - const stopped = await stopSandboxesForTicket(ticketKey); - expect(stopped).toBe(0); - - // 7. Ticket remains in AI column (skipped, not moved). Jira returns the - // canonical display name, which may differ in case from COLUMN_AI — - // production code lowercases on both sides for comparison. - const status = await getTicketStatus(ticketKey); - expect(status.toLowerCase()).toBe(e2eEnv.COLUMN_AI.toLowerCase()); - }); -}); diff --git a/e2e/tier2/us9-failed-marker-cleared.test.ts b/e2e/tier2/us9-failed-marker-cleared.test.ts deleted file mode 100644 index bcb92bd..0000000 --- a/e2e/tier2/us9-failed-marker-cleared.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { - createTestTicket, - moveTicketToColumn, - getTicketStatus, - deleteTicket, -} from "../helpers/jira.js"; -import { deleteBranch } from "../helpers/github.js"; -import { - cleanup as redisCleanup, - markFailed, - isTicketFailed, - cleanupFailed, -} from "../helpers/redis.js"; -import { stopSandboxesForTicket } from "../helpers/sandbox.js"; -import { callCronPoll } from "../helpers/cron.js"; -import { waitFor } from "../helpers/wait.js"; -import { e2eEnv } from "../env.js"; - -/** - * US-9: Failed marker is cleared when a ticket leaves the AI column - * - * Reconcile (part of the cron poll) lists all failure markers and clears any - * whose ticket is no longer in the AI column snapshot. After clearing, the - * ticket can be retried on a future re-entry into AI. - */ -describe("US-9: Failed marker cleared when ticket leaves AI", () => { - let ticketKey: string; - let branchName: string; - - afterAll(async () => { - if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); - if (branchName) await deleteBranch(branchName).catch(() => {}); - if (ticketKey) { - await cleanupFailed(ticketKey); - await redisCleanup(ticketKey); - await deleteTicket(ticketKey); - } - }); - - it("clears the failure marker on reconcile when the ticket is not in AI", async () => { - // 1. Create a ticket and move it to Backlog — anything outside AI works. - const ticket = await createTestTicket({ - summary: "[E2E] Failed marker clears on reconcile", - description: - "Ticket sits outside AI; reconcile should clear the seeded failure marker.", - }); - ticketKey = ticket.ticketKey; - branchName = `blazebot/${ticketKey.toLowerCase()}`; - - await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_BACKLOG); - - // 2. Seed a failure marker in Redis - await markFailed(ticketKey, { - runId: "run_e2e_seeded", - error: "seeded by e2e test", - failedAt: new Date().toISOString(), - }); - expect(await isTicketFailed(ticketKey)).toBe(true); - - // 3. Trigger cron — runs reconcileRuns which clears markers for tickets - // not in the AI column snapshot - const res = await callCronPoll(); - expect(res.status).toBe(200); - - // 4. Marker is cleared (allow a brief propagation window) - await waitFor( - async () => ((await isTicketFailed(ticketKey)) ? null : true), - { - description: `failure marker cleared for ${ticketKey}`, - timeoutMs: 30_000, - }, - ); - - // 5. Ticket is still in Backlog — reconcile never moves tickets - expect(await getTicketStatus(ticketKey)).toBe(e2eEnv.COLUMN_BACKLOG); - }); -}); From 4d2bce0474979fb630f9dfbad6ae7b11be5d02d0 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 15:00:05 +0200 Subject: [PATCH 60/71] fix: ci/cd order --- .github/workflows/e2e.yml | 54 ++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f8e88f3..240039a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,7 +1,9 @@ name: E2E Tests on: - push: + workflow_run: + workflows: ["CI"] + types: [completed] branches: [main, dev] workflow_dispatch: inputs: @@ -9,17 +11,19 @@ on: description: "Which suite to run" type: choice options: - - agent - orchestration - capacity + - agent - all default: all jobs: - e2e-agent: - if: github.event_name == 'push' || inputs.tier == 'agent' || inputs.tier == 'all' + e2e-orchestration: + if: | + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || + (github.event_name == 'workflow_dispatch' && (inputs.tier == 'orchestration' || inputs.tier == 'all')) runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 60 environment: e2e env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} @@ -42,6 +46,8 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 @@ -50,23 +56,24 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test:e2e:agent + - run: pnpm run test:e2e:orchestration - if: failure() uses: actions/upload-artifact@v4 with: - name: e2e-agent-results + name: e2e-orchestration-results path: | e2e/**/*.log retention-days: 7 - e2e-orchestration: - needs: [e2e-agent] + e2e-capacity: + needs: [e2e-orchestration] if: | always() && - (needs.e2e-agent.result == 'success' || needs.e2e-agent.result == 'skipped') && - (github.event_name == 'push' || inputs.tier == 'orchestration' || inputs.tier == 'all') + needs.e2e-orchestration.result == 'success' && + (github.event_name == 'workflow_run' || + (github.event_name == 'workflow_dispatch' && (inputs.tier == 'capacity' || inputs.tier == 'all'))) runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 30 environment: e2e env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} @@ -89,6 +96,8 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 @@ -97,23 +106,24 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test:e2e:orchestration + - run: pnpm run test:e2e:capacity - if: failure() uses: actions/upload-artifact@v4 with: - name: e2e-orchestration-results + name: e2e-capacity-results path: | e2e/**/*.log retention-days: 7 - e2e-capacity: - needs: [e2e-orchestration] + e2e-agent: + needs: [e2e-capacity] if: | always() && - (needs.e2e-orchestration.result == 'success' || needs.e2e-orchestration.result == 'skipped') && - (github.event_name == 'push' || inputs.tier == 'capacity' || inputs.tier == 'all') + needs.e2e-capacity.result == 'success' && + (github.event_name == 'workflow_run' || + (github.event_name == 'workflow_dispatch' && (inputs.tier == 'agent' || inputs.tier == 'all'))) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 120 environment: e2e env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} @@ -136,6 +146,8 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 @@ -144,11 +156,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test:e2e:capacity + - run: pnpm run test:e2e:agent - if: failure() uses: actions/upload-artifact@v4 with: - name: e2e-capacity-results + name: e2e-agent-results path: | e2e/**/*.log retention-days: 7 From cb20329fdd3263355f3bf4634cc02dc9e8c3d93b Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 15:12:49 +0200 Subject: [PATCH 61/71] feat: new ci --- .github/workflows/ci.yml | 129 ++++++++++++++++++++++++++++++++++++++ .github/workflows/e2e.yml | 26 ++------ 2 files changed, 135 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3adf5..7336658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,132 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run typecheck - run: pnpm run test + + e2e-orchestration: + needs: [ci] + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: e2e + env: + E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} + JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} + COLUMN_AI: ${{ secrets.COLUMN_AI }} + COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} + COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} + AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.12" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:e2e:orchestration + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-orchestration-results + path: | + e2e/**/*.log + retention-days: 7 + + e2e-capacity: + needs: [e2e-orchestration] + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: e2e + env: + E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} + JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} + COLUMN_AI: ${{ secrets.COLUMN_AI }} + COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} + COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} + AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.12" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:e2e:capacity + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-capacity-results + path: | + e2e/**/*.log + retention-days: 7 + + e2e-agent: + needs: [e2e-capacity] + runs-on: ubuntu-latest + timeout-minutes: 120 + environment: e2e + env: + E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} + JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} + COLUMN_AI: ${{ secrets.COLUMN_AI }} + COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} + COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} + E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} + AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.12" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:e2e:agent + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-agent-results + path: | + e2e/**/*.log + retention-days: 7 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 240039a..19c40f6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,10 +1,6 @@ -name: E2E Tests +name: E2E Tests (Manual) on: - workflow_run: - workflows: ["CI"] - types: [completed] - branches: [main, dev] workflow_dispatch: inputs: tier: @@ -19,9 +15,7 @@ on: jobs: e2e-orchestration: - if: | - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || - (github.event_name == 'workflow_dispatch' && (inputs.tier == 'orchestration' || inputs.tier == 'all')) + if: inputs.tier == 'orchestration' || inputs.tier == 'all' runs-on: ubuntu-latest timeout-minutes: 60 environment: e2e @@ -46,8 +40,6 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 @@ -69,9 +61,8 @@ jobs: needs: [e2e-orchestration] if: | always() && - needs.e2e-orchestration.result == 'success' && - (github.event_name == 'workflow_run' || - (github.event_name == 'workflow_dispatch' && (inputs.tier == 'capacity' || inputs.tier == 'all'))) + (needs.e2e-orchestration.result == 'success' || needs.e2e-orchestration.result == 'skipped') && + (inputs.tier == 'capacity' || inputs.tier == 'all') runs-on: ubuntu-latest timeout-minutes: 30 environment: e2e @@ -96,8 +87,6 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 @@ -119,9 +108,8 @@ jobs: needs: [e2e-capacity] if: | always() && - needs.e2e-capacity.result == 'success' && - (github.event_name == 'workflow_run' || - (github.event_name == 'workflow_dispatch' && (inputs.tier == 'agent' || inputs.tier == 'all'))) + (needs.e2e-capacity.result == 'success' || needs.e2e-capacity.result == 'skipped') && + (inputs.tier == 'agent' || inputs.tier == 'all') runs-on: ubuntu-latest timeout-minutes: 120 environment: e2e @@ -146,8 +134,6 @@ jobs: MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - uses: pnpm/action-setup@v4 with: version: 10 From 7858406fdb7ce4cfa297fd68f95a41f6a6d36c92 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 15:17:54 +0200 Subject: [PATCH 62/71] fix; duplicate ci --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7336658..fde55b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,14 @@ name: CI on: push: - branches: [main, dev] + branches: [main] pull_request: branches: [main, dev] +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: ci: runs-on: ubuntu-latest From 125f75ce91c3fa05391f01fd33cb58df8c0ea198 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 20 Apr 2026 15:33:08 +0200 Subject: [PATCH 63/71] fix: e2e on pr --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde55b5..36068fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main, dev] From 5fcfc8d396c628f8efa2ef553485ab8324c1e63a Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 23 Apr 2026 14:05:48 +0200 Subject: [PATCH 64/71] feat: add vercel credential auth --- .env.e2e.example | 7 +++++++ e2e/env.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.env.e2e.example b/.env.e2e.example index ad19c46..42a800e 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -25,3 +25,10 @@ AI_WORKFLOW_KV_REST_API_TOKEN= # Vercel Deployment Protection bypass (optional, needed for preview URLs) # VERCEL_AUTOMATION_BYPASS_SECRET= + +# Vercel Sandbox credentials for the e2e runner (Sandbox.list / Sandbox.create +# in e2e/helpers/sandbox.ts and us02). Set all three to use a long-lived PAT +# instead of re-pasting a rotating OIDC token from `vercel env pull`. +# VERCEL_TOKEN= +# VERCEL_TEAM_ID= +# VERCEL_PROJECT_ID= diff --git a/e2e/env.ts b/e2e/env.ts index afb1534..e0b3162 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -29,6 +29,18 @@ const schema = z.object({ VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), + /** + * Persistent Vercel Sandbox credentials used by the e2e runner itself + * (see e2e/helpers/sandbox.ts and us02-attachments). When all three are + * set the @vercel/sandbox SDK skips OIDC, removing the need to re-paste + * a rotating VERCEL_OIDC_TOKEN from `vercel env pull`. The SDK reads + * them from process.env directly; declaring them here just documents + * support and surfaces them on the typed e2eEnv object. + */ + VERCEL_TOKEN: z.string().min(1).optional(), + VERCEL_TEAM_ID: z.string().min(1).optional(), + VERCEL_PROJECT_ID: z.string().min(1).optional(), + /** * Must match the deployed app's MAX_CONCURRENT_AGENTS. US-11 creates * this many dummy sandboxes to saturate the dispatch capacity check. From 9fe265a7dedc690738212f77747e7b7a3a1159d6 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 23 Apr 2026 14:12:31 +0200 Subject: [PATCH 65/71] fix: e2e vercel auth --- e2e/env.ts | 9 +++++---- e2e/helpers/sandbox.ts | 22 ++++++++++++++++++---- e2e/tier2/us02-attachments.test.ts | 4 ++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/e2e/env.ts b/e2e/env.ts index e0b3162..ec4ab86 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -32,10 +32,11 @@ const schema = z.object({ /** * Persistent Vercel Sandbox credentials used by the e2e runner itself * (see e2e/helpers/sandbox.ts and us02-attachments). When all three are - * set the @vercel/sandbox SDK skips OIDC, removing the need to re-paste - * a rotating VERCEL_OIDC_TOKEN from `vercel env pull`. The SDK reads - * them from process.env directly; declaring them here just documents - * support and surfaces them on the typed e2eEnv object. + * set, getSandboxCredentials() (src/sandbox/credentials.ts) returns them + * and e2e Sandbox.create/list/get calls pass them explicitly, skipping + * OIDC — no need to re-paste a rotating VERCEL_OIDC_TOKEN from + * `vercel env pull`. The SDK itself does NOT auto-read these from + * process.env; callers must spread them into each Sandbox.* call. */ VERCEL_TOKEN: z.string().min(1).optional(), VERCEL_TEAM_ID: z.string().min(1).optional(), diff --git a/e2e/helpers/sandbox.ts b/e2e/helpers/sandbox.ts index 08df96b..3e726a3 100644 --- a/e2e/helpers/sandbox.ts +++ b/e2e/helpers/sandbox.ts @@ -8,7 +8,11 @@ export async function stopSandboxesForTicket( const expectedBranch = `blazebot/${ticketKey.trim().toLowerCase()}`; try { const { Sandbox } = await import("@vercel/sandbox"); - const { json } = await Sandbox.list({ limit: 100 }); + const { getSandboxCredentials } = await import( + "../../src/sandbox/credentials.js" + ); + const credentials = getSandboxCredentials(); + const { json } = await Sandbox.list({ ...credentials, limit: 100 }); const running = json.sandboxes.filter( (s: { status?: string }) => s.status === "running", ); @@ -16,7 +20,10 @@ export async function stopSandboxesForTicket( let stopped = 0; for (const entry of running) { try { - const sandbox = await Sandbox.get({ sandboxId: entry.id }); + const sandbox = await Sandbox.get({ + ...credentials, + sandboxId: entry.id, + }); if (sandbox.status !== "running") continue; const result = await sandbox.runCommand({ @@ -62,13 +69,20 @@ export async function killClaudeForTicket( ): Promise { const expectedBranch = `blazebot/${ticketKey.trim().toLowerCase()}`; const { Sandbox } = await import("@vercel/sandbox"); - const { json } = await Sandbox.list({ limit: 100 }); + const { getSandboxCredentials } = await import( + "../../src/sandbox/credentials.js" + ); + const credentials = getSandboxCredentials(); + const { json } = await Sandbox.list({ ...credentials, limit: 100 }); const running = json.sandboxes.filter( (s: { status?: string }) => s.status === "running", ); for (const entry of running) { - const sandbox = await Sandbox.get({ sandboxId: entry.id }); + const sandbox = await Sandbox.get({ + ...credentials, + sandboxId: entry.id, + }); if (sandbox.status !== "running") continue; const branchResult = await sandbox.runCommand({ diff --git a/e2e/tier2/us02-attachments.test.ts b/e2e/tier2/us02-attachments.test.ts index 0eab49d..b21bf68 100644 --- a/e2e/tier2/us02-attachments.test.ts +++ b/e2e/tier2/us02-attachments.test.ts @@ -105,7 +105,11 @@ describe("US-02: Ticket with attachments (real pipeline)", () => { // 5. Create a sandbox and write files using the same pattern as the workflow const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import( + "../../src/sandbox/credentials.js" + ); const sbx = await Sandbox.create({ + ...getSandboxCredentials(), source: { type: "git", url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`, From f41688568725cbe540a29412e501a6984732206c Mon Sep 17 00:00:00 2001 From: kasin-it Date: Thu, 23 Apr 2026 14:16:47 +0200 Subject: [PATCH 66/71] fix: vercel auth --- .github/workflows/e2e.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 19c40f6..1a3f1bc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,7 +36,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 @@ -83,7 +85,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 @@ -130,7 +134,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 From 51eb274ed5fcaf2d8e67d0797effce7f8c87f543 Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:10:08 +0200 Subject: [PATCH 67/71] Aiw 34 arthur integration (#58) * feat: arthur integration * feat: add prompts from arthur * fix: fixed model * feat: add vercel credential auth * fix: e2e vercel auth * fix: vercel auth * feat: add e2e env check * feat: error handling * fix: ci --- .env.example | 7 + .github/workflows/ci.yml | 27 +- .github/workflows/e2e.yml | 15 + .../2026-04-21-arthur-tracer-in-sandbox.md | 773 ++++++++++++++++++ .../plans/2026-04-22-arthur-hosted-prompts.md | 626 ++++++++++++++ env.ts | 6 + package.json | 3 + pnpm-lock.yaml | 56 +- scripts/build-arthur-tracer.mjs | 43 + scripts/clear-run-registry.ts | 75 ++ scripts/setup-arthur-prompts.ts | 69 ++ scripts/test-arthur-ensure.ts | 30 + scripts/test-arthur-sandbox.ts | 102 +++ src/lib/prompts.ts | 10 + src/sandbox/arthur-client.test.ts | 242 ++++++ src/sandbox/arthur-client.ts | 174 ++++ src/sandbox/arthur-tracer.ts | 8 + src/sandbox/manager.test.ts | 160 +++- src/sandbox/manager.ts | 201 ++++- src/workflows/agent.ts | 50 +- src/workflows/prompts-step.test.ts | 86 ++ src/workflows/prompts-step.ts | 58 ++ 22 files changed, 2755 insertions(+), 66 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-21-arthur-tracer-in-sandbox.md create mode 100644 docs/superpowers/plans/2026-04-22-arthur-hosted-prompts.md create mode 100644 scripts/build-arthur-tracer.mjs create mode 100644 scripts/clear-run-registry.ts create mode 100644 scripts/setup-arthur-prompts.ts create mode 100644 scripts/test-arthur-ensure.ts create mode 100644 scripts/test-arthur-sandbox.ts create mode 100644 src/sandbox/arthur-client.test.ts create mode 100644 src/sandbox/arthur-client.ts create mode 100644 src/sandbox/arthur-tracer.ts create mode 100644 src/workflows/prompts-step.test.ts create mode 100644 src/workflows/prompts-step.ts diff --git a/.env.example b/.env.example index 8d24802..149cc3e 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,13 @@ CLAUDE_MODEL=claude-opus-4-6 COMMIT_AUTHOR=ai-workflow-blazity COMMIT_EMAIL=ai-workflow@blazity.com +# Arthur AI Engine (optional — tracing + hosted prompts) +# Set both API_KEY and TRACE_ENDPOINT to install the tracer into every sandbox. +# Set PROMPT_TASK_ID after running `npx tsx scripts/setup-arthur-prompts.ts`. +# GENAI_ENGINE_API_KEY= +# GENAI_ENGINE_TRACE_ENDPOINT=https://your-arthur-host/api/v1/traces +# GENAI_ENGINE_PROMPT_TASK_ID= + # Sandbox MAX_CONCURRENT_AGENTS=3 JOB_TIMEOUT_MS=1800000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36068fd..509d109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 @@ -59,6 +61,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:orchestration - if: failure() uses: actions/upload-artifact@v4 @@ -90,7 +97,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 @@ -102,6 +111,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:capacity - if: failure() uses: actions/upload-artifact@v4 @@ -133,7 +147,9 @@ jobs: AI_WORKFLOW_KV_REST_API_URL: ${{ secrets.AI_WORKFLOW_KV_REST_API_URL }} AI_WORKFLOW_KV_REST_API_TOKEN: ${{ secrets.AI_WORKFLOW_KV_REST_API_TOKEN }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} steps: - uses: actions/checkout@v4 @@ -145,6 +161,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:agent - if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1a3f1bc..09bdc7f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,6 +50,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:orchestration - if: failure() uses: actions/upload-artifact@v4 @@ -99,6 +104,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:capacity - if: failure() uses: actions/upload-artifact@v4 @@ -148,6 +158,11 @@ jobs: node-version: "20.12" cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify Vercel Sandbox credentials present + run: | + : "${VERCEL_TOKEN:?VERCEL_TOKEN missing in e2e environment}" + : "${VERCEL_TEAM_ID:?VERCEL_TEAM_ID missing in e2e environment}" + : "${VERCEL_PROJECT_ID:?VERCEL_PROJECT_ID missing in e2e environment}" - run: pnpm run test:e2e:agent - if: failure() uses: actions/upload-artifact@v4 diff --git a/docs/superpowers/plans/2026-04-21-arthur-tracer-in-sandbox.md b/docs/superpowers/plans/2026-04-21-arthur-tracer-in-sandbox.md new file mode 100644 index 0000000..a6b2865 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-arthur-tracer-in-sandbox.md @@ -0,0 +1,773 @@ +# Arthur Tracer In Sandbox Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Install the Arthur AI Engine Claude Code tracer inside every Vercel Sandbox the workflow provisions, so every in-sandbox Claude Code turn emits OpenInference spans to a configured Arthur instance. Credentials are optional — if any of the three Arthur env vars is missing, provisioning behaves exactly as today. + +**Architecture:** Bundle `arthur-engine/integrations/claude-code/claude_code_tracer.py` as a base64 string in a generated TS file so Nitro reliably includes it in the Vercel deployment. Extend `SandboxConfig` with an optional `arthur` block. In `SandboxManager.provision()`, after Claude Code is installed, pip-install two `opentelemetry` packages, write the tracer to `$HOME/.claude/hooks/claude_code_tracer.py`, write `$HOME/.claude/arthur_config.json`, and merge Arthur's five hook entries (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Stop`) into `$HOME/.claude/settings.json`. Centralise every write to `settings.json` in a single merge-aware helper so `configureStopHookInSandbox` no longer clobbers Arthur's hooks when it toggles the commit-guard Stop entry. + +**Tech Stack:** TypeScript, Vitest, `@vercel/sandbox` (`writeFiles`, `runCommand`), `@t3-oss/env-core` + Zod, Python 3 + pip (runs inside sandbox), Node 24 (runs inside sandbox for JSON merges). + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `env.ts` | **Modify** | Three optional server vars: `GENAI_ENGINE_API_KEY`, `GENAI_ENGINE_TASK_ID`, `GENAI_ENGINE_TRACE_ENDPOINT`. | +| `scripts/build-arthur-tracer.mjs` | **Create** | Generates `src/sandbox/arthur-tracer.ts` from `../arthur-engine/integrations/claude-code/claude_code_tracer.py`. | +| `src/sandbox/arthur-tracer.ts` | **Create (generated, checked in)** | Exports `ARTHUR_TRACER_PY_BASE64: string`. Regenerated via `pnpm build:arthur-tracer`. | +| `package.json` | **Modify** | Add script `"build:arthur-tracer": "node scripts/build-arthur-tracer.mjs"`. | +| `src/sandbox/manager.ts` | **Modify** | Extend `SandboxConfig.arthur`; add `installArthurTracer(sandbox, arthur)`; replace the heredoc writers in `configureStopHookInSandbox` with a single merge-aware helper `writeClaudeSettings(sandbox, opts)`. Call `installArthurTracer` from `provision()` after `installGlobalSkills`. | +| `src/sandbox/manager.test.ts` | **Modify** | Rewrite the two stop-hook tests to assert the new `node -e` merge call; add three Arthur tests (installs when configured, skipped when not, registers all five hook commands). | +| `src/workflows/agent.ts` | **Modify** | Build `arthur` config block from env once; pass into `SandboxManager`. `configureStopHook` signature unchanged. | +| `.gitignore` | **Modify** | No change — `src/sandbox/arthur-tracer.ts` is checked in. | + +No changes to `arthur-engine/` (read-only source), VCS adapters, run registry, Slack, cron. + +--- + +## Shared Types (referenced by multiple tasks) + +Defined in Task 3, reproduced here so later tasks don't have to repeat the shape: + +```ts +// src/sandbox/manager.ts +export interface ArthurConfig { + apiKey: string; // GENAI_ENGINE_API_KEY + taskId: string; // GENAI_ENGINE_TASK_ID (UUID) + endpoint: string; // GENAI_ENGINE_TRACE_ENDPOINT (full URL incl. /api/v1/traces) +} + +export interface SandboxConfig { + // ...existing fields unchanged... + arthur?: ArthurConfig; +} +``` + +--- + +## Task 1: Add Arthur env vars + +**Files:** +- Modify: `env.ts` + +- [ ] **Step 1: Add the three new optional vars to the `server` block** + +In `env.ts`, inside the `server: { ... }` object in `createEnv(...)`, add the following entries directly after the `// Agent` group (after `COMMIT_EMAIL`, before `// Sandbox`): + +```ts + // Arthur AI Engine (optional — all three required together) + GENAI_ENGINE_API_KEY: z.string().min(1).optional(), + GENAI_ENGINE_TASK_ID: z.string().uuid().optional(), + GENAI_ENGINE_TRACE_ENDPOINT: z.string().url().optional(), +``` + +No cross-field validation needed in the `createEnv` call — the "either all three or none" rule is enforced at the only use site (`src/workflows/agent.ts`, see Task 6). + +- [ ] **Step 2: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS (the optional fields won't break anything). + +- [ ] **Step 3: Commit** + +```bash +git add env.ts +git commit -m "feat(env): add optional Arthur AI Engine env vars" +``` + +--- + +## Task 2: Build script for the tracer bundle + +**Files:** +- Create: `scripts/build-arthur-tracer.mjs` +- Modify: `package.json` + +Nitro's Vercel preset does not reliably bundle arbitrary `.py` files that sit next to `.ts` sources, so we embed the Python tracer as a base64 string in a generated TS file that Nitro will treat as source. The build script is run manually (and can be re-run whenever Arthur's tracer is updated upstream). + +- [ ] **Step 1: Write the build script** + +Create `scripts/build-arthur-tracer.mjs` with this exact content: + +```js +#!/usr/bin/env node +// Generates src/sandbox/arthur-tracer.ts from the Arthur Engine tracer source. +// Regenerate whenever arthur-engine/integrations/claude-code/claude_code_tracer.py changes. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const defaultSource = path.resolve( + repoRoot, + "..", + "arthur-engine", + "integrations", + "claude-code", + "claude_code_tracer.py", +); +const sourcePath = process.env.ARTHUR_TRACER_SRC + ? path.resolve(process.env.ARTHUR_TRACER_SRC) + : defaultSource; + +if (!fs.existsSync(sourcePath)) { + console.error(`Arthur tracer not found at ${sourcePath}.`); + console.error("Set ARTHUR_TRACER_SRC to override."); + process.exit(1); +} + +const bytes = fs.readFileSync(sourcePath); +const base64 = bytes.toString("base64"); +const outPath = path.resolve(repoRoot, "src", "sandbox", "arthur-tracer.ts"); + +const out = `// AUTO-GENERATED — do not edit by hand. +// Source: ${path.relative(repoRoot, sourcePath)} +// Regenerate: pnpm build:arthur-tracer +// +// Base64-encoded Python source of the Arthur Engine Claude Code tracer. +// Bundled so Nitro reliably ships it with the Vercel deployment; decoded at +// runtime and written into each provisioned sandbox under ~/.claude/hooks/. +export const ARTHUR_TRACER_PY_BASE64 = "${base64}"; +`; + +fs.writeFileSync(outPath, out); +console.log(`Wrote ${path.relative(repoRoot, outPath)} (${bytes.length} bytes -> ${base64.length} base64 chars)`); +``` + +- [ ] **Step 2: Add the npm script** + +Edit `package.json`, insert in the `"scripts"` block (after `"typecheck"` is fine): + +```json + "build:arthur-tracer": "node scripts/build-arthur-tracer.mjs", +``` + +- [ ] **Step 3: Run the script** + +Run: `pnpm build:arthur-tracer` +Expected output: `Wrote src/sandbox/arthur-tracer.ts (59174 bytes -> 78900 base64 chars)` (exact numbers will vary with tracer version). + +- [ ] **Step 4: Sanity-check the generated file** + +Run: `head -c 200 src/sandbox/arthur-tracer.ts` +Expected: starts with `// AUTO-GENERATED` comment, then `export const ARTHUR_TRACER_PY_BASE64 = "...`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build-arthur-tracer.mjs package.json src/sandbox/arthur-tracer.ts +git commit -m "feat(sandbox): bundle Arthur tracer source via build script" +``` + +--- + +## Task 3: Extend `SandboxConfig` with `arthur` block + +**Files:** +- Modify: `src/sandbox/manager.ts` + +Surgical type change; no runtime behaviour yet. Isolating the type change lets later tasks focus on logic. + +- [ ] **Step 1: Add the `ArthurConfig` interface and extend `SandboxConfig`** + +In `src/sandbox/manager.ts`, directly above the existing `export interface SandboxConfig {` block (~line 14), add: + +```ts +export interface ArthurConfig { + apiKey: string; + taskId: string; + endpoint: string; +} +``` + +Then inside `SandboxConfig`, append at the end (after `jobTimeoutMs`): + +```ts + /** Arthur AI Engine tracing config. If set, the tracer is installed into every provisioned sandbox. */ + arthur?: ArthurConfig; +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS (optional field, no call sites yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/sandbox/manager.ts +git commit -m "feat(sandbox): add optional ArthurConfig to SandboxConfig" +``` + +--- + +## Task 4: Centralise `settings.json` writes + +**Files:** +- Modify: `src/sandbox/manager.ts` +- Modify: `src/sandbox/manager.test.ts` + +The existing `configureStopHookInSandbox` writes a full `~/.claude/settings.json` via a shell heredoc, which would wipe Arthur's hooks when toggled between phases. Replace it with `writeClaudeSettings(sandbox, opts)` — a single helper that always merges into the current file. + +The merge logic runs inside the sandbox via `node -e` (node 24 is the runtime — always available). It takes a single JSON argument describing what to mutate: + +- `{"commitGuard":"enable"}` — add the commit-guard Stop hook entry if absent +- `{"commitGuard":"disable"}` — remove the commit-guard Stop hook entry if present +- `{"arthur":"install"}` — append the five Arthur hook entries if absent (idempotent by exact command string) + +Multiple keys can be combined. The helper does **not** touch hook entries it doesn't own. + +- [ ] **Step 1: Write the failing tests** + +Replace the two existing stop-hook tests (`manager.test.ts:114-159`) with the following, and add two new ones. After the existing `"writes CLAUDE_CODE_OAUTH_TOKEN..."` test (~line 112), rewrite/extend this block: + +```ts + it("enabling the stop hook runs a node merge script that adds commit-guard", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + const sandbox = await manager.provision("feat/test-branch"); + mockRunCommand.mockClear(); + + await manager.configureStopHook(sandbox, true); + + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes("commit-guard.sh") && + c[1][2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); + }); + + it("disabling the stop hook runs a node merge script with commitGuard=disable", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + const sandbox = await manager.provision("feat/test-branch"); + mockRunCommand.mockClear(); + + await manager.configureStopHook(sandbox, false); + + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"commitGuard":"disable"'), + ); + expect(mergeCall).toBeDefined(); + }); + + it("configureStopHookInSandbox works with any sandbox-like object", async () => { + const fakeSandbox = { runCommand: mockRunCommand }; + mockRunCommand.mockClear(); + + await configureStopHookInSandbox(fakeSandbox as any, true); + + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); + }); +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm test -- manager.test.ts` +Expected: FAIL — the old heredoc writer doesn't call `node -e`. + +- [ ] **Step 3: Implement the merge helper** + +In `src/sandbox/manager.ts`, **replace** the entire body of `configureStopHookInSandbox` (lines 64-92) and **replace** the `cat > ~/.claude/settings.json << 'JSON' ... JSON` / `echo '{}' > ~/.claude/settings.json` writes with the new helper. Add the new helper directly above `configureStopHookInSandbox`: + +```ts +/** + * Merge-aware writer for ~/.claude/settings.json inside a sandbox. + * + * Accepts a partial "directive" — only the keys provided are mutated; existing + * hook entries (including those owned by other tools, e.g. Arthur's tracer) + * are preserved. The merge itself runs inside the sandbox via `node -e` + * because Node 24 is the sandbox runtime and we can't assume Python is + * available for stop-hook toggling. + */ +async function writeClaudeSettings( + sandbox: RunnableSandbox, + opts: { + commitGuard?: "enable" | "disable"; + arthur?: "install"; + }, +): Promise { + const directive = JSON.stringify(opts); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const home = process.env.HOME; + const settingsPath = path.join(home, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsertHook = (event, matcher, command) => { + const existing = s.hooks[event] || []; + const has = existing.some(e => (e && Array.isArray(e.hooks) ? e.hooks : []).some(h => h && h.command === command)); + if (!has) existing.push({ matcher, hooks: [{ type: 'command', command }] }); + s.hooks[event] = existing; + }; + const removeHook = (event, commandPredicate) => { + const existing = s.hooks[event] || []; + s.hooks[event] = existing + .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !commandPredicate(h.command || '')) })) + .filter(e => (e.hooks || []).length > 0); + }; + + if (opts.commitGuard === 'enable') { + upsertHook('Stop', '', 'bash ~/.claude/commit-guard.sh'); + } else if (opts.commitGuard === 'disable') { + removeHook('Stop', c => c.includes('commit-guard.sh')); + } + + if (opts.arthur === 'install') { + const events = [ + ['UserPromptSubmit', 'user_prompt_submit'], + ['PreToolUse', 'pre_tool'], + ['PostToolUse', 'post_tool'], + ['PostToolUseFailure', 'post_tool_failure'], + ['Stop', 'stop'], + ]; + for (const [event, arg] of events) { + upsertHook(event, '', 'python3 "$HOME/.claude/hooks/claude_code_tracer.py" ' + arg); + } + } + + fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); + `; + // Note: we serialise opts into the script body twice — the JSON.stringify above + // injects the literal, which is what the test assertions look for. The + // \`directive\` string is included below purely to make the intent grep-able + // when reading runtime logs. (It does not affect behaviour.) + void directive; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); +} + +export async function configureStopHookInSandbox(sandbox: RunnableSandbox, enabled: boolean): Promise { + // Ensure the commit-guard script exists before toggling the hook (idempotent). + await sandbox.runCommand("bash", [ + "-c", + [ + `mkdir -p ~/.claude`, + `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, + `#!/bin/bash`, + `input=$(cat)`, + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + ` exit 2`, + `fi`, + `SCRIPT`, + `chmod +x ~/.claude/commit-guard.sh`, + ].join("\n"), + ]); + + await writeClaudeSettings(sandbox, { commitGuard: enabled ? "enable" : "disable" }); +} +``` + +Also **remove** the `SandboxManager.configureStopHook` method's body change is unnecessary — it already delegates. Leave `SandboxManager.configureStopHook` (lines ~229-231) as-is. + +- [ ] **Step 4: Export `writeClaudeSettings` for Task 5's use** + +At the bottom of `src/sandbox/manager.ts`, the helper lives inside the module scope. Task 5 will call it from `installArthurTracer` which also lives in the same module, so no export needed. Leave it as an internal helper. + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: `pnpm test -- manager.test.ts` +Expected: PASS — all five tests in `manager.test.ts` green. + +- [ ] **Step 6: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/sandbox/manager.ts src/sandbox/manager.test.ts +git commit -m "refactor(sandbox): merge-aware settings.json writer" +``` + +--- + +## Task 5: Install Arthur tracer inside `provision()` + +**Files:** +- Modify: `src/sandbox/manager.ts` +- Modify: `src/sandbox/manager.test.ts` + +Now wire the Arthur install into `provision()`. The install is a no-op when `config.arthur` is undefined, so existing tests keep passing without setting it. + +Install order inside `provision()`: + +1. (existing) `npm install -g @anthropic-ai/claude-code` +2. (existing) write `agent-env.sh` +3. (existing) onboarding `~/.claude.json` +4. (existing) `installGlobalSkills` +5. **(new)** `installArthurTracer` — only if `config.arthur` is set + +- [ ] **Step 1: Write the failing tests** + +In `src/sandbox/manager.test.ts`, append three new tests in the existing `describe` block (after the last one): + +```ts + it("installs Arthur tracer when config.arthur is set", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + arthur: { + apiKey: "test-key", + taskId: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }, + }); + + await manager.provision("feat/test-branch"); + + const pipCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "bash" && + typeof c[1]?.[1] === "string" && + c[1][1].includes("pip3 install") && + c[1][1].includes("opentelemetry-sdk") && + c[1][1].includes("opentelemetry-exporter-otlp-proto-http"), + ); + expect(pipCall).toBeDefined(); + + const arthurMergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"arthur":"install"') && + c[1][2].includes("user_prompt_submit") && + c[1][2].includes("pre_tool") && + c[1][2].includes("post_tool") && + c[1][2].includes("post_tool_failure"), + ); + expect(arthurMergeCall).toBeDefined(); + }); + + it("skips Arthur install when config.arthur is undefined", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + await manager.provision("feat/test-branch"); + + const pipCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "bash" && typeof c[1]?.[1] === "string" && c[1][1].includes("pip3 install"), + ); + expect(pipCall).toBeUndefined(); + }); + + it("Arthur install writes arthur_config.json and the tracer script", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + arthur: { + apiKey: "test-key", + taskId: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }, + }); + + await manager.provision("feat/test-branch"); + + // Every writeFiles call passes an array of { path, content }. Flatten them. + const written = mockWriteFiles.mock.calls.flatMap(([files]: any[]) => files); + const tracerFile = written.find((f: any) => f.path.endsWith("arthur-tracer.py")); + expect(tracerFile).toBeDefined(); + expect(Buffer.isBuffer(tracerFile.content)).toBe(true); + expect(tracerFile.content.length).toBeGreaterThan(1000); + + const configFile = written.find((f: any) => f.path.endsWith("arthur_config.json")); + expect(configFile).toBeDefined(); + const cfg = JSON.parse(Buffer.from(configFile.content).toString()); + expect(cfg).toEqual({ + api_key: "test-key", + task_id: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }); + }); +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm test -- manager.test.ts` +Expected: FAIL — `installArthurTracer` doesn't exist yet. + +- [ ] **Step 3: Implement `installArthurTracer`** + +In `src/sandbox/manager.ts`, add this import at the top of the file (after existing imports): + +```ts +import { ARTHUR_TRACER_PY_BASE64 } from "./arthur-tracer.js"; +``` + +Then inside the `SandboxManager` class, directly below `installGlobalSkills`, add: + +```ts + /** + * Install the Arthur AI Engine Claude Code tracer into the sandbox. + * + * No-op if the three credentials are not all configured on the SandboxManager. + * The tracer hooks into every Claude Code turn and exports OpenInference spans + * via OTLP/HTTP to the configured endpoint. + * + * If pip install fails (e.g. missing python3, offline), we log and return + * without registering hooks — failing hooks would block Claude Code turns. + */ + private async installArthurTracer(sandbox: SandboxInstance): Promise { + const arthur = this.config.arthur; + if (!arthur) return; + + const { logger } = await import("../lib/logger.js"); + + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m pip install --user --quiet opentelemetry-sdk>=1.20.0 opentelemetry-exporter-otlp-proto-http>=1.20.0", + ]); + if (pip.exitCode !== 0) { + const err = (await pip.stderr()).trim(); + logger.warn({ err: err.slice(0, 500) }, "arthur_pip_install_failed"); + return; + } + + // Stage tracer to /tmp, then relocate (writeFiles takes absolute paths; $HOME + // isn't expanded by the API, only by shell commands). + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([ + { path: "/tmp/arthur-tracer.py", content: tracerBytes }, + ]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py", + ]); + + // Write the config file. Priority-2 location per Arthur's README. + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, + null, + 2, + ); + await sandbox.writeFiles([ + { path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }, + ]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json", + ]); + + // Register all five Arthur hooks via the merge-aware writer. + await writeClaudeSettings(sandbox, { arthur: "install" }); + } +``` + +Then in `provision()`, **after** the existing call `await this.installGlobalSkills(sandbox);` (near the end of the method, just before `return sandbox;`), add: + +```ts + await this.installArthurTracer(sandbox); +``` + +- [ ] **Step 4: Run the tests** + +Run: `pnpm test -- manager.test.ts` +Expected: PASS — all eight tests in `manager.test.ts` green. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/sandbox/manager.ts src/sandbox/manager.test.ts +git commit -m "feat(sandbox): install Arthur AI tracer during provision" +``` + +--- + +## Task 6: Wire env into the workflow + +**Files:** +- Modify: `src/workflows/agent.ts` + +`provisionSandbox` builds the `SandboxConfig`; this is where the "all three or none" rule lives. + +- [ ] **Step 1: Build the `arthur` block from env and pass it into `SandboxManager`** + +In `src/workflows/agent.ts`, find the `new SandboxManager({...})` call at line ~159 and replace it with: + +```ts + const arthur = + env.GENAI_ENGINE_API_KEY && env.GENAI_ENGINE_TASK_ID && env.GENAI_ENGINE_TRACE_ENDPOINT + ? { + apiKey: env.GENAI_ENGINE_API_KEY, + taskId: env.GENAI_ENGINE_TASK_ID, + endpoint: env.GENAI_ENGINE_TRACE_ENDPOINT, + } + : undefined; + + const manager = new SandboxManager({ + kind: vcs.kind, + token: vcs.token, + repoPath: vcs.repoPath, + host: vcs.host, + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + claudeModel: env.CLAUDE_MODEL, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + arthur, + }); +``` + +No changes needed to `configureStopHook` (the helper in `agent.ts`, line ~205) — `writeClaudeSettings` preserves Arthur's hooks automatically. + +- [ ] **Step 2: Typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Run the full test suite** + +Run: `pnpm test` +Expected: PASS across the board. `agent.test`-style workflow tests should not regress (they either mock `SandboxManager` or don't look at the arthur field). + +- [ ] **Step 4: Commit** + +```bash +git add src/workflows/agent.ts +git commit -m "feat(workflow): pass Arthur config from env into SandboxManager" +``` + +--- + +## Task 7: Local end-to-end smoke test + +**Files:** +- None (verification only) + +- [ ] **Step 1: Expose local Arthur via ngrok** + +Run in a separate terminal: `ngrok http 3030` +Copy the `https://...ngrok-free.app` URL. + +- [ ] **Step 2: Create an Arthur task and grab its UUID** + +Open `http://localhost:3030`, sign in with `changeme_genai_engine_admin_key`, create a task, copy its UUID. + +- [ ] **Step 3: Configure `.env`** + +Add to `.env` in the repo root: + +```env +GENAI_ENGINE_API_KEY=changeme_genai_engine_admin_key +GENAI_ENGINE_TASK_ID= +GENAI_ENGINE_TRACE_ENDPOINT=https://.ngrok-free.app/api/v1/traces +``` + +- [ ] **Step 4: Ensure the tracer bundle is fresh** + +Run: `pnpm build:arthur-tracer` + +- [ ] **Step 5: Start the dev server and dispatch one ticket** + +Run: `pnpm dev` +In Jira, transition one ticket to the AI column (or wait for the cron sweep). The workflow will provision a sandbox with Arthur wired in. + +- [ ] **Step 6: Verify in Arthur UI** + +Watch Arthur's task view. Within ~30s of the agent starting, you should see: +- One `claude-code-turn` trace per user prompt inside the sandbox +- Child spans: `LLM` (claude/claude-sonnet-*), `TOOL` (Read/Edit/Bash/etc.), `RETRIEVER` (WebSearch/WebFetch), `AGENT` (Task) +- The `arthur.session` resource attribute grouping spans from the same Claude Code session + +- [ ] **Step 7: Negative check** + +Unset one of the three `GENAI_ENGINE_*` vars in `.env`, restart `pnpm dev`, dispatch another ticket. Confirm the sandbox provisions and the ticket processes exactly as today — no Arthur HTTP calls (tail ngrok's request log to confirm zero traffic), no broken hooks. + +--- + +## Verification + +1. **Unit tests**: `pnpm test` — green across all suites. +2. **Typecheck**: `pnpm typecheck` — green. +3. **Manual smoke test**: Task 7 end-to-end — traces appear in Arthur UI, unset-credentials path is a clean no-op. + +## Risks / Open items + +- **Python availability in sandbox**: Vercel's `node24` runtime image includes `python3` + `pip3`, but if a future image change removes them, `pip3 install` fails, `installArthurTracer` logs a warning and returns early — provisioning continues unaffected. No hooks get registered, so no broken turns. +- **Tracer drift**: `src/sandbox/arthur-tracer.ts` is a snapshot. Re-run `pnpm build:arthur-tracer` and redeploy whenever Arthur ships a new tracer. +- **Bundle size**: +~80KB to the deployed JS artifact. Acceptable. +- **Networking**: The sandbox hits whatever URL is in `GENAI_ENGINE_TRACE_ENDPOINT`. For local dev that's ngrok; for prod, deploy Arthur somewhere with a stable public URL and swap the env var. diff --git a/docs/superpowers/plans/2026-04-22-arthur-hosted-prompts.md b/docs/superpowers/plans/2026-04-22-arthur-hosted-prompts.md new file mode 100644 index 0000000..7891276 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-arthur-hosted-prompts.md @@ -0,0 +1,626 @@ +# Arthur-Hosted Prompts With Codebase Fallback + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `research-plan`, `implement`, and `review` prompts be edited in Arthur without code changes, while keeping the current hardcoded strings as an automatic fallback when Arthur isn't configured or is unreachable. When Arthur *is* configured with a "prompt host" task, the workflow fetches the `production`-tagged version of each prompt at the start of every run. On any failure (missing env, 404, network error) it silently falls back to the hardcoded strings. + +**Architecture:** + +- New env var `GENAI_ENGINE_PROMPT_TASK_ID` — UUID of a dedicated Arthur task whose only job is to host the three prompts. Kept separate from the per-run trace tasks (`AWT-42`, `AWT-42.1`, …) so prompt edits don't require re-seeding per ticket. +- `ArthurClient` gains three prompt methods (`getPromptByTag`, `createPromptVersion`, `tagPromptVersion`). Each prompt is stored in Arthur as a single-message chat (`[{role: "user", content: ""}]`) and retrieved back via `messages[0].content`. +- A new workflow step `loadPrompts()` runs once per workflow run, returns `{research, implement, review}`, and logs the source (`arthur` or `fallback`) per prompt. Result is checkpointed in workflow history so replays reuse the same strings. +- Three `getPrompt("research-plan.md" | "implement.md" | "review.md")` call sites in `src/workflows/agent.ts` are replaced by indexing into the `loadPrompts()` return value. +- One-shot `pnpm setup:arthur-prompts` script **creates-or-finds** a task named `ai-workflow-prompts`, seeds the three prompts on it (each saved as a new version, tagged `production`), and prints the UUID in a paste-ready `GENAI_ENGINE_PROMPT_TASK_ID=` line. Idempotent — re-running finds the existing task and creates new versions, so it's safe after prompt edits. + +**Tech Stack:** TypeScript, Vitest, native `fetch` (same pattern as `src/adapters/issue-tracker/jira.ts`), `@t3-oss/env-core` + Zod, Workflow DevKit (`"use step"`). + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `env.ts` | **Modify** | Add optional `GENAI_ENGINE_PROMPT_TASK_ID: z.string().uuid().optional()`. | +| `src/sandbox/arthur-client.ts` | **Modify** | Add `getPromptByTag`, `createPromptVersion`, `tagPromptVersion`. Export a helper type for the agentic-prompt response. | +| `src/sandbox/arthur-client.test.ts` | **Modify** | Add 5 tests covering the three new methods (happy path, 404 returns null, auth + body shape for save + tag). | +| `src/lib/prompts.ts` | **Modify** | Export a new `PROMPT_NAMES` array + `PROMPT_FALLBACKS` record mapped by Arthur prompt name (no `.md`). Keep `getPrompt(name)` for backwards compat (now delegates to the fallbacks record keyed by `.md` filename). | +| `src/workflows/prompts-step.ts` | **Create** | New module housing the `loadPrompts()` step. Single export. Contains its own `"use step"` directive. Returns `{research: string, implement: string, review: string}` and per-prompt source logging. | +| `src/workflows/prompts-step.test.ts` | **Create** | 4 unit tests: (a) no Arthur env → fallback for all three; (b) Arthur returns all three → Arthur wins; (c) Arthur 404 on one → that one falls back, other two come from Arthur; (d) `PROMPT_TASK_ID` set but `API_KEY` missing → fallback (invalid config treated as disabled). | +| `src/workflows/agent.ts` | **Modify** | Call `loadPrompts()` once near the top of the workflow body (right after `fetchAndValidateTicket`). Replace the three inline `getPrompt(...)` calls with `prompts.research` / `.implement` / `.review`. | +| `scripts/setup-arthur-prompts.ts` | **Create** | Find-or-create the `ai-workflow-prompts` task, seed the three prompts on it, tag each version `production`, print the paste-ready `GENAI_ENGINE_PROMPT_TASK_ID=` line. Requires `GENAI_ENGINE_API_KEY` + `GENAI_ENGINE_TRACE_ENDPOINT` in `.env`. | +| `package.json` | **Modify** | Add script `"setup:arthur-prompts": "tsx scripts/setup-arthur-prompts.ts"`. | + +No changes to `configureStopHook`, Arthur tracer install, SandboxManager, or any VCS/issue-tracker adapter. The prompt-host task is auto-created on first setup run; re-runs find it by name and seed new versions — so there's at most one `ai-workflow-prompts` task per Arthur instance. + +--- + +## Shared Types + +```ts +// src/sandbox/arthur-client.ts +export interface AgenticPrompt { + name: string; + messages: Array<{ role: string; content: string; /* other OpenAI fields ignored */ }>; + version?: number | string; // Arthur returns this — used for tagging +} +``` + +```ts +// src/workflows/prompts-step.ts +export interface LoadedPrompts { + research: string; + implement: string; + review: string; +} +``` + +`PROMPT_NAMES` (defined in `src/lib/prompts.ts`) is the canonical list used by both the seed script and `loadPrompts()`: + +```ts +export const PROMPT_NAMES = ["research-plan", "implement", "review"] as const; +export type PromptName = typeof PROMPT_NAMES[number]; +``` + +--- + +## Task 1: Env var for the prompt-host task + +**Files:** `env.ts` + +- [ ] **Step 1:** In `env.ts`, add below the existing Arthur env vars (immediately after `GENAI_ENGINE_TRACE_ENDPOINT`): + +```ts + GENAI_ENGINE_PROMPT_TASK_ID: z.string().uuid().optional(), +``` + +- [ ] **Step 2:** Run `pnpm typecheck`. Expect PASS. + +- [ ] **Step 3:** Commit: `git add env.ts && git commit -m "feat(env): add optional GENAI_ENGINE_PROMPT_TASK_ID"`. + +--- + +## Task 2: Expose prompt names + fallbacks for shared use + +**Files:** `src/lib/prompts.ts` + +- [ ] **Step 1:** At the top of `src/lib/prompts.ts` (below the existing three `const ...Prompt = \`…\`` blocks, above `const prompts: Record`), add: + +```ts +export const PROMPT_NAMES = ["research-plan", "implement", "review"] as const; +export type PromptName = typeof PROMPT_NAMES[number]; + +/** Fallback strings keyed by Arthur prompt name (no `.md` suffix). */ +export const PROMPT_FALLBACKS: Record = { + "research-plan": researchPlanPrompt, + "implement": implementPrompt, + "review": reviewPrompt, +}; +``` + +Leave the existing `prompts` record and `getPrompt()` export untouched — no caller is being moved in this task. + +- [ ] **Step 2:** `pnpm typecheck`. PASS. + +- [ ] **Step 3:** Commit: `git add src/lib/prompts.ts && git commit -m "refactor(prompts): expose PROMPT_NAMES and PROMPT_FALLBACKS"`. + +--- + +## Task 3: `ArthurClient` prompt methods + +**Files:** `src/sandbox/arthur-client.ts`, `src/sandbox/arthur-client.test.ts` + +- [ ] **Step 1:** Write failing tests. Append to `src/sandbox/arthur-client.test.ts` (inside the existing `describe("ArthurClient", ...)`): + +```ts + describe("prompts", () => { + it("getPromptByTag returns messages[0].content on 200", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + name: "research-plan", + version: 3, + messages: [{ role: "user", content: "the prompt body" }], + })); + const client = new ArthurClient("http://host", "k"); + const body = await client.getPromptByTag("task-uuid", "research-plan", "production"); + expect(body).toBe("the prompt body"); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/task-uuid/prompts/research-plan/versions/tags/production"); + }); + + it("getPromptByTag returns null on 404", async () => { + mockFetch.mockResolvedValueOnce(new Response("not found", { status: 404 })); + const client = new ArthurClient("http://host", "k"); + expect(await client.getPromptByTag("t", "research-plan", "production")).toBeNull(); + }); + + it("createPromptVersion POSTs single-message body with user role", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + name: "implement", + version: 5, + messages: [{ role: "user", content: "x" }], + })); + const client = new ArthurClient("http://host", "k"); + const result = await client.createPromptVersion("task-uuid", "implement", "x"); + expect(result.version).toBe(5); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/task-uuid/prompts/implement"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body.messages).toEqual([{ role: "user", content: "x" }]); + }); + + it("tagPromptVersion PUTs the tag", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ name: "review", version: 2, messages: [] })); + const client = new ArthurClient("http://host", "k"); + await client.tagPromptVersion("t", "review", 2, "production"); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/t/prompts/review/versions/2/tags"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body)).toEqual({ tag: "production" }); + }); + + it("getPromptByTag throws on non-404 non-2xx (5xx)", async () => { + mockFetch.mockResolvedValueOnce(new Response("boom", { status: 500 })); + const client = new ArthurClient("http://host", "k"); + await expect(client.getPromptByTag("t", "x", "production")).rejects.toThrow(/500/); + }); + }); +``` + +- [ ] **Step 2:** Run `pnpm test -- arthur-client.test.ts`. Expect FAIL — methods don't exist. + +- [ ] **Step 3:** Add an interface export and three methods to `src/sandbox/arthur-client.ts`. Place the interface right below `ArthurTask`: + +```ts +export interface AgenticPrompt { + name: string; + version?: number | string; + messages: Array<{ role: string; content: string }>; +} +``` + +Then add these methods inside `ArthurClient`, right after `ensureTaskForTicket`: + +```ts + /** Fetch a tagged prompt version. Returns the first message's content, or null if 404. */ + async getPromptByTag(taskId: string, name: string, tag: string): Promise { + const path = `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}/versions/tags/${encodeURIComponent(tag)}`; + const res = await fetch(`${this.baseUrl}${path}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "ngrok-skip-browser-warning": "true", + }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Arthur GET ${path} → ${res.status}: ${body.slice(0, 300)}`); + } + const prompt = (await res.json()) as AgenticPrompt; + const first = prompt.messages?.[0]; + return first?.content ?? null; + } + + /** Create a new version of a named prompt on a task. Content is sent as a single user message. */ + async createPromptVersion(taskId: string, name: string, content: string): Promise { + return this.request( + `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}`, + { + method: "POST", + body: JSON.stringify({ + messages: [{ role: "user", content }], + model_name: "claude-sonnet-4", + model_provider: "anthropic", + }), + }, + ); + } + + /** Add a tag (e.g. "production") to a specific version. */ + async tagPromptVersion(taskId: string, name: string, version: number | string, tag: string): Promise { + await this.request( + `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}/versions/${encodeURIComponent(String(version))}/tags`, + { + method: "PUT", + body: JSON.stringify({ tag }), + }, + ); + } +``` + +Note on `getPromptByTag`: we intentionally **don't** use `request()` for it because 404 is a valid "not found" signal that must not throw — it's the fallback trigger. The save/tag methods *do* use `request()` because any non-2xx there is a genuine failure. + +- [ ] **Step 4:** Run `pnpm test -- arthur-client.test.ts`. Expect PASS (15 tests total: 10 existing + 5 new). + +- [ ] **Step 5:** `pnpm typecheck`. PASS. + +- [ ] **Step 6:** Commit: `git add src/sandbox/arthur-client.ts src/sandbox/arthur-client.test.ts && git commit -m "feat(arthur-client): add prompt get/create/tag methods"`. + +--- + +## Task 4: `loadPrompts()` step + +**Files:** `src/workflows/prompts-step.ts`, `src/workflows/prompts-step.test.ts` + +The step must be in its own file so Vitest can import it directly (importing from `agent.ts` pulls in the whole workflow DevKit). It is exported and called from `agent.ts` in Task 5. + +- [ ] **Step 1:** Write the failing tests in `src/workflows/prompts-step.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../env.js", () => ({ env: {} })); + +const mockGetPromptByTag = vi.fn(); +vi.mock("../sandbox/arthur-client.js", () => ({ + ArthurClient: { + fromTraceEndpoint: vi.fn(() => ({ getPromptByTag: mockGetPromptByTag })), + }, +})); + +import { loadPrompts } from "./prompts-step.js"; +import { PROMPT_FALLBACKS } from "../lib/prompts.js"; + +function setEnv(partial: Record) { + const mod = require("../../env.js") as { env: Record }; + mod.env = { ...mod.env, ...partial }; +} + +describe("loadPrompts", () => { + beforeEach(() => { + mockGetPromptByTag.mockReset(); + setEnv({ + GENAI_ENGINE_API_KEY: undefined, + GENAI_ENGINE_TRACE_ENDPOINT: undefined, + GENAI_ENGINE_PROMPT_TASK_ID: undefined, + }); + }); + + it("returns fallbacks when no Arthur env is set", async () => { + const result = await loadPrompts(); + expect(result.research).toBe(PROMPT_FALLBACKS["research-plan"]); + expect(result.implement).toBe(PROMPT_FALLBACKS["implement"]); + expect(result.review).toBe(PROMPT_FALLBACKS["review"]); + expect(mockGetPromptByTag).not.toHaveBeenCalled(); + }); + + it("returns fallbacks when PROMPT_TASK_ID is missing even if key+endpoint are set", async () => { + setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: undefined, + }); + const result = await loadPrompts(); + expect(result.research).toBe(PROMPT_FALLBACKS["research-plan"]); + expect(mockGetPromptByTag).not.toHaveBeenCalled(); + }); + + it("returns Arthur prompts when all three are present", async () => { + setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: "prompt-task-uuid", + }); + mockGetPromptByTag + .mockResolvedValueOnce("arthur research") + .mockResolvedValueOnce("arthur implement") + .mockResolvedValueOnce("arthur review"); + const result = await loadPrompts(); + expect(result).toEqual({ + research: "arthur research", + implement: "arthur implement", + review: "arthur review", + }); + expect(mockGetPromptByTag).toHaveBeenCalledTimes(3); + const names = mockGetPromptByTag.mock.calls.map((c) => c[1]); + expect(names).toEqual(["research-plan", "implement", "review"]); + }); + + it("falls back per-prompt when Arthur returns null or throws", async () => { + setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: "prompt-task-uuid", + }); + mockGetPromptByTag + .mockResolvedValueOnce("arthur research") + .mockResolvedValueOnce(null) // implement missing + .mockRejectedValueOnce(new Error("boom")); // review errors + + const result = await loadPrompts(); + expect(result.research).toBe("arthur research"); + expect(result.implement).toBe(PROMPT_FALLBACKS["implement"]); + expect(result.review).toBe(PROMPT_FALLBACKS["review"]); + }); +}); +``` + +- [ ] **Step 2:** Run `pnpm test -- prompts-step.test.ts`. Expect FAIL — the file doesn't exist. + +- [ ] **Step 3:** Create `src/workflows/prompts-step.ts`: + +```ts +import type { LoadedPrompts } from "./prompts-step-types.js"; + +export interface LoadedPrompts { + research: string; + implement: string; + review: string; +} + +export async function loadPrompts(): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { logger } = await import("../lib/logger.js"); + const { PROMPT_FALLBACKS } = await import("../lib/prompts.js"); + + const arthurEnabled = + !!env.GENAI_ENGINE_API_KEY && + !!env.GENAI_ENGINE_TRACE_ENDPOINT && + !!env.GENAI_ENGINE_PROMPT_TASK_ID; + + if (!arthurEnabled) { + logger.info({ source: "fallback", reason: "arthur_prompts_disabled" }, "prompts_loaded"); + return { + research: PROMPT_FALLBACKS["research-plan"], + implement: PROMPT_FALLBACKS["implement"], + review: PROMPT_FALLBACKS["review"], + }; + } + + const { ArthurClient } = await import("../sandbox/arthur-client.js"); + const client = ArthurClient.fromTraceEndpoint( + env.GENAI_ENGINE_TRACE_ENDPOINT!, + env.GENAI_ENGINE_API_KEY!, + ); + const taskId = env.GENAI_ENGINE_PROMPT_TASK_ID!; + const TAG = "production"; + + async function one(name: "research-plan" | "implement" | "review"): Promise { + try { + const body = await client.getPromptByTag(taskId, name, TAG); + if (body === null) { + logger.info({ name, source: "fallback", reason: "arthur_prompt_missing" }, "prompts_loaded"); + return PROMPT_FALLBACKS[name]; + } + logger.info({ name, source: "arthur" }, "prompts_loaded"); + return body; + } catch (err) { + logger.warn({ name, source: "fallback", err: (err as Error).message }, "prompts_loaded"); + return PROMPT_FALLBACKS[name]; + } + } + + const [research, implement, review] = await Promise.all([ + one("research-plan"), + one("implement"), + one("review"), + ]); + return { research, implement, review }; +} +loadPrompts.maxRetries = 0; +``` + +Remove the bad `import type` line (the duplicate) before saving — the interface is defined inline. + +- [ ] **Step 4:** Run `pnpm test -- prompts-step.test.ts`. Expect PASS (4 tests). + +- [ ] **Step 5:** `pnpm typecheck`. PASS. + +- [ ] **Step 6:** Commit: `git add src/workflows/prompts-step.ts src/workflows/prompts-step.test.ts && git commit -m "feat(workflow): loadPrompts step with per-prompt Arthur→codebase fallback"`. + +--- + +## Task 5: Wire `loadPrompts()` into the workflow + +**Files:** `src/workflows/agent.ts` + +- [ ] **Step 1:** In `src/workflows/agent.ts`, right after `const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return;`, add: + +```ts + const { loadPrompts } = await import("./prompts-step.js"); + const prompts = await loadPrompts(); +``` + +- [ ] **Step 2:** Replace the three `getPrompt(...)` call sites: + +| Before | After | +|---|---| +| `prompt: getPrompt("research-plan.md")` | `prompt: prompts.research` | +| `prompt: getPrompt("implement.md")` | `prompt: prompts.implement` | +| `prompt: getPrompt("review.md")` *(commented)* | `prompt: prompts.review` *(commented — leave commented same as today)* | + +- [ ] **Step 3:** Remove the now-unused `const { getPrompt } = await import("../lib/prompts.js");` import inside the workflow body. + +- [ ] **Step 4:** `pnpm typecheck`. PASS. + +- [ ] **Step 5:** `pnpm test`. All suites green (existing + new). + +- [ ] **Step 6:** Commit: `git add src/workflows/agent.ts && git commit -m "feat(workflow): use loadPrompts instead of getPrompt"`. + +--- + +## Task 6: One-shot setup script (find-or-create task + seed + print UUID) + +**Files:** `scripts/setup-arthur-prompts.ts`, `package.json` + +We need two supporting `ArthurClient` helpers that Task 3 didn't add. Rather than retro-editing Task 3, they're added here because they're only used by this script. + +- [ ] **Step 1:** Extend `ArthurClient` with `findTaskByName(name)` and `createPlainTask(name)`. In `src/sandbox/arthur-client.ts`, add these methods directly below `ensureTaskForTicket`: + +```ts + /** Exact-name lookup. Returns the task if found (non-archived), else null. */ + async findTaskByName(name: string): Promise { + const { tasks } = await this.request<{ count: number; tasks: ArthurTask[] }>( + "/api/v2/tasks/search", + { method: "POST", body: JSON.stringify({ task_name: name }) }, + ); + return tasks.find((t) => t.name === name && !t.is_archived) ?? null; + } + + /** Create a task without the agent-metadata/is_agentic defaults used by ensureTaskForTicket. */ + async createPlainTask(name: string): Promise { + return this.request("/api/v2/tasks", { + method: "POST", + body: JSON.stringify({ name, is_agentic: true }), + }); + } +``` + +*(Note: `createPlainTask` body is identical to `createTask` today. Kept as a separate method so its usage signals "for non-ticket tasks" — a semantic marker to prevent future code from assuming ticket-naming conventions on prompt-host tasks.)* + +- [ ] **Step 2:** Add unit tests for the two new methods in `src/sandbox/arthur-client.test.ts` (inside the existing describe): + +```ts + describe("findTaskByName", () => { + it("returns exact-name match, excluding archived", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + count: 3, + tasks: [ + { id: "a", name: "ai-workflow-prompts" }, + { id: "b", name: "ai-workflow-prompts-old", is_archived: true }, + { id: "c", name: "ai-workflow-prompts", is_archived: true }, + ], + })); + const client = new ArthurClient("http://host", "k"); + const t = await client.findTaskByName("ai-workflow-prompts"); + expect(t?.id).toBe("a"); + }); + + it("returns null on no match", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ count: 0, tasks: [] })); + const client = new ArthurClient("http://host", "k"); + expect(await client.findTaskByName("nothing")).toBeNull(); + }); + }); +``` + +- [ ] **Step 3:** Run `pnpm test -- arthur-client.test.ts`. Expect PASS. + +- [ ] **Step 4:** Create `scripts/setup-arthur-prompts.ts`: + +```ts +/** + * One-shot setup: ensures the Arthur prompt-host task exists and has the three + * workflow prompts seeded with the `production` tag. + * + * npx tsx scripts/setup-arthur-prompts.ts + * + * Requires in .env: + * GENAI_ENGINE_API_KEY + * GENAI_ENGINE_TRACE_ENDPOINT + * + * Prints the task UUID as a paste-ready env line at the end. + */ +import "dotenv/config"; +import { ArthurClient } from "../src/sandbox/arthur-client.js"; +import { PROMPT_FALLBACKS, PROMPT_NAMES } from "../src/lib/prompts.js"; + +const TASK_NAME = "ai-workflow-prompts"; +const TAG = "production"; + +const apiKey = process.env.GENAI_ENGINE_API_KEY; +const endpoint = process.env.GENAI_ENGINE_TRACE_ENDPOINT; +if (!apiKey || !endpoint) { + console.error("Missing GENAI_ENGINE_{API_KEY,TRACE_ENDPOINT} in env/.env"); + process.exit(1); +} + +const client = ArthurClient.fromTraceEndpoint(endpoint, apiKey); + +async function main() { + let task = await client.findTaskByName(TASK_NAME); + if (task) { + console.log(`Found existing task "${TASK_NAME}" (${task.id}) — will overwrite prompts.`); + } else { + task = await client.createPlainTask(TASK_NAME); + console.log(`Created new task "${TASK_NAME}" (${task.id}).`); + } + + for (const name of PROMPT_NAMES) { + const body = PROMPT_FALLBACKS[name]; + console.log(`\n seeding ${name}…`); + const created = await client.createPromptVersion(task.id, name, body); + const version = created.version; + if (version === undefined) { + console.error(` no version returned; cannot tag. full response:`, created); + continue; + } + await client.tagPromptVersion(task.id, name, version, TAG); + console.log(` ✓ version ${version} tagged "${TAG}"`); + } + + console.log(`\nSetup complete. Add this to .env:\n GENAI_ENGINE_PROMPT_TASK_ID=${task.id}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +- [ ] **Step 5:** In `package.json` add to `"scripts"`: + +```json + "setup:arthur-prompts": "tsx scripts/setup-arthur-prompts.ts", +``` + +- [ ] **Step 6:** `pnpm typecheck`. PASS. + +- [ ] **Step 7:** Commit: + +```bash +git add src/sandbox/arthur-client.ts src/sandbox/arthur-client.test.ts scripts/setup-arthur-prompts.ts package.json +git commit -m "feat(scripts): setup-arthur-prompts — find-or-create task and seed prompts" +``` + +--- + +## Task 7: Manual verification + +**Files:** None (runtime verification only). + +- [ ] **Step 1:** Ensure `GENAI_ENGINE_API_KEY` and `GENAI_ENGINE_TRACE_ENDPOINT` are set and uncommented in `.env`. + +- [ ] **Step 2:** Run setup: + +```bash +pnpm setup:arthur-prompts +``` + +Expected output: either `Created new task …` or `Found existing task …`, followed by three `✓ version N tagged "production"` lines, and a final: + +```text +Setup complete. Add this to .env: + GENAI_ENGINE_PROMPT_TASK_ID= +``` + +- [ ] **Step 3:** Copy that line into `.env`. + +- [ ] **Step 4:** In Arthur UI, verify the `ai-workflow-prompts` task exists with three prompts, each having a version tagged `production`. + +- [ ] **Step 5:** Start `pnpm dev`, trigger a fresh ticket. Grep dev-server output for `prompts_loaded` — expect three lines, each with `source: "arthur"`: + +```text +msg=prompts_loaded name=research-plan source=arthur +msg=prompts_loaded name=implement source=arthur +msg=prompts_loaded name=review source=arthur +``` + +- [ ] **Step 6:** Negative check — comment out `GENAI_ENGINE_PROMPT_TASK_ID` in `.env`, restart `pnpm dev`, trigger another ticket. Expect a single `prompts_loaded source=fallback reason=arthur_prompts_disabled` line and no per-prompt `arthur` source log. + +- [ ] **Step 7:** Per-prompt fallback check — temporarily delete one prompt (e.g. `review`) from the Arthur UI, restart `pnpm dev`, trigger another ticket. Expect two `source=arthur` lines and one `source=fallback reason=arthur_prompt_missing name=review`. Re-run `pnpm setup:arthur-prompts` to restore. + +--- + +## Verification + +1. `pnpm test` — all suites green. +2. `pnpm typecheck` — green. +3. Task 7 manual flow — three positive, two negative, all matching expected log lines. + +## Risks / Open Items + +- **Race between seed and workflow start.** If a workflow run begins while the seed script is mid-flight, the workflow might see an incomplete prompt set and fall back for the missing ones. The per-prompt fallback makes this safe (no broken run), just visible in logs. Acceptable. +- **No automatic task creation.** We don't auto-create the prompt-host task because the prompts API needs a task ID *before* any prompt exists, and accidentally creating many such tasks would be confusing. Manual setup keeps the invariant "at most one prompt-host task" explicit. Documented in Task 7 Step 1. +- **`model_name` / `model_provider` are required by `POST /prompts/{name}`** per the API schema. We send the current workflow's model (`claude-sonnet-4`, `anthropic`) as placeholders — Arthur's tracing doesn't consume these fields for hosted prompts, and we ignore them on read. If Arthur starts validating compatibility, we'd revisit. +- **Replay consistency.** `loadPrompts()` is a `"use step"` with `maxRetries = 0`, so once the workflow records a result it reuses it on replay. This means prompts mid-flight never change under the workflow's feet. Tradeoff: an urgent prompt fix won't affect a workflow already past the `loadPrompts()` checkpoint — operators must dispatch a new run. +- **Bundle size / cold-start.** `prompts-step.ts` adds ~1KB to the deployed JS. Insignificant. +- **No cost pricing for prompt storage.** Arthur charges per trace; hosted prompts are free. Confirmed with API docs — no additional env/billing concern. diff --git a/env.ts b/env.ts index 9fdc8e4..3640564 100644 --- a/env.ts +++ b/env.ts @@ -46,6 +46,12 @@ export const env = createEnv({ COMMIT_AUTHOR: z.string().default("ai-workflow-blazity"), COMMIT_EMAIL: z.string().default("ai-workflow@blazity.com"), + // Arthur AI Engine (optional — both required together). One task per run + // is auto-created, so there is no static GENAI_ENGINE_TASK_ID. + GENAI_ENGINE_API_KEY: z.string().min(1).optional(), + GENAI_ENGINE_TRACE_ENDPOINT: z.string().url().optional(), + GENAI_ENGINE_PROMPT_TASK_ID: z.string().uuid().optional(), + // Sandbox MAX_CONCURRENT_AGENTS: z.coerce.number().int().positive().default(3), JOB_TIMEOUT_MS: z.coerce.number().int().positive().default(1_800_000), diff --git a/package.json b/package.json index 208f9cc..5b8b48c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "build:arthur-tracer": "node scripts/build-arthur-tracer.mjs", + "setup:arthur-prompts": "tsx scripts/setup-arthur-prompts.ts", "test:e2e": "pnpm test:e2e:agent && pnpm test:e2e:orchestration && pnpm test:e2e:capacity", "test:e2e:agent": "vitest run --config e2e/vitest.e2e.config.ts --project agent", "test:e2e:orchestration": "vitest run --config e2e/vitest.e2e.config.ts --project orchestration", @@ -31,6 +33,7 @@ "devDependencies": { "@workflow/vitest": "latest", "@workflow/world-postgres": "latest", + "tsx": "^4.21.0", "typescript": "^5.8", "vitest": "^3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29071bf..5a1cd3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,16 +47,19 @@ importers: devDependencies: '@workflow/vitest': specifier: latest - version: 4.0.1-beta.8(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))(zod@3.25.76) + version: 4.0.1-beta.8(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0))(zod@3.25.76) '@workflow/world-postgres': specifier: latest version: 4.1.0-beta.46(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.8 version: 5.9.3 vitest: specifier: ^3 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) packages: @@ -2462,6 +2465,9 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -3498,6 +3504,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -3862,6 +3871,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -5619,13 +5633,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5862,16 +5876,16 @@ snapshots: - aws-crt - supports-color - '@workflow/vitest@4.0.1-beta.8(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))(zod@3.25.76)': + '@workflow/vitest@4.0.1-beta.8(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0))(zod@3.25.76)': dependencies: '@workflow/builders': 4.0.1-beta.62 '@workflow/core': 4.2.0-beta.71 '@workflow/rollup': 4.0.0-beta.28 '@workflow/world': 4.1.0-beta.13(zod@3.25.76) '@workflow/world-local': 4.1.0-beta.44 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) transitivePeerDependencies: - '@opentelemetry/api' - '@swc/helpers' @@ -6944,6 +6958,10 @@ snapshots: get-stream@8.0.1: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -8204,6 +8222,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -8619,6 +8639,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + type-fest@0.21.3: {} type-fest@4.41.0: {} @@ -8797,13 +8824,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1): + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -8818,7 +8845,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1): + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -8831,12 +8858,13 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 terser: 5.46.1 + tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8854,8 +8882,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) - vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 diff --git a/scripts/build-arthur-tracer.mjs b/scripts/build-arthur-tracer.mjs new file mode 100644 index 0000000..736a2f6 --- /dev/null +++ b/scripts/build-arthur-tracer.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Generates src/sandbox/arthur-tracer.ts from the Arthur Engine tracer source. +// Regenerate whenever arthur-engine/integrations/claude-code/claude_code_tracer.py changes. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const defaultSource = path.resolve( + repoRoot, + "..", + "arthur-engine", + "integrations", + "claude-code", + "claude_code_tracer.py", +); +const sourcePath = process.env.ARTHUR_TRACER_SRC + ? path.resolve(process.env.ARTHUR_TRACER_SRC) + : defaultSource; + +if (!fs.existsSync(sourcePath)) { + console.error(`Arthur tracer not found at ${sourcePath}.`); + console.error("Set ARTHUR_TRACER_SRC to override."); + process.exit(1); +} + +const bytes = fs.readFileSync(sourcePath); +const base64 = bytes.toString("base64"); +const outPath = path.resolve(repoRoot, "src", "sandbox", "arthur-tracer.ts"); + +const out = `// AUTO-GENERATED — do not edit by hand. +// Source: ${path.relative(repoRoot, sourcePath)} +// Regenerate: pnpm build:arthur-tracer +// +// Base64-encoded Python source of the Arthur Engine Claude Code tracer. +// Bundled so Nitro reliably ships it with the Vercel deployment; decoded at +// runtime and written into each provisioned sandbox under ~/.claude/hooks/. +export const ARTHUR_TRACER_PY_BASE64 = "${base64}"; +`; + +fs.writeFileSync(outPath, out); +console.log(`Wrote ${path.relative(repoRoot, outPath)} (${bytes.length} bytes -> ${base64.length} base64 chars)`); diff --git a/scripts/clear-run-registry.ts b/scripts/clear-run-registry.ts new file mode 100644 index 0000000..0e52251 --- /dev/null +++ b/scripts/clear-run-registry.ts @@ -0,0 +1,75 @@ +/** + * Clear run-registry entries in Upstash. + * + * pnpm exec tsx scripts/clear-run-registry.ts # show state, no writes + * pnpm exec tsx scripts/clear-run-registry.ts AWT-42 # clear one ticket + * pnpm exec tsx scripts/clear-run-registry.ts --all # clear every ticket in this env + */ +import "dotenv/config"; +import { Redis } from "@upstash/redis"; + +const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; +const keys = { + active: `blazebot:active-runs:${ENV_PREFIX}`, + sandbox: `blazebot:sandboxes:${ENV_PREFIX}`, + entryTs: `blazebot:entry-timestamps:${ENV_PREFIX}`, + failed: `blazebot:failed-tickets:${ENV_PREFIX}`, +}; + +const url = process.env.AI_WORKFLOW_KV_REST_API_URL; +const token = process.env.AI_WORKFLOW_KV_REST_API_TOKEN; +if (!url || !token) { + console.error("Missing AI_WORKFLOW_KV_REST_API_URL / AI_WORKFLOW_KV_REST_API_TOKEN"); + process.exit(1); +} +const redis = new Redis({ url, token }); + +async function dump() { + for (const [label, key] of Object.entries(keys)) { + const all = await redis.hgetall>(key); + console.log(`\n[${label}] ${key}`); + if (!all || Object.keys(all).length === 0) console.log(" (empty)"); + else for (const [t, v] of Object.entries(all)) console.log(` ${t} -> ${v}`); + } +} + +async function clearTicket(t: string) { + for (const [label, key] of Object.entries(keys)) { + const n = await redis.hdel(key, t); + console.log(` hdel ${label} ${t} -> ${n}`); + } +} + +async function clearAll() { + for (const [label, key] of Object.entries(keys)) { + const n = await redis.del(key); + console.log(` del ${label} ${key} -> ${n}`); + } +} + +const args = process.argv.slice(2); +(async () => { + if (args.length === 0) { + console.log(`env=${ENV_PREFIX} — dumping current state (no writes)`); + await dump(); + return; + } + if (args[0] === "--all") { + if (args.length !== 2 || args[1] !== "--yes") { + console.error( + `env=${ENV_PREFIX} — refusing to clear ALL run-registry keys without confirmation.\n` + + ` re-run with: pnpm exec tsx scripts/clear-run-registry.ts --all --yes`, + ); + process.exit(1); + } + console.log(`env=${ENV_PREFIX} — clearing ALL run-registry keys`); + await clearAll(); + return; + } + if (args.length !== 1) { + console.error(`env=${ENV_PREFIX} — unexpected extra args: ${args.slice(1).join(" ")}`); + process.exit(1); + } + console.log(`env=${ENV_PREFIX} — clearing ticket ${args[0]}`); + await clearTicket(args[0]); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/setup-arthur-prompts.ts b/scripts/setup-arthur-prompts.ts new file mode 100644 index 0000000..1e9e112 --- /dev/null +++ b/scripts/setup-arthur-prompts.ts @@ -0,0 +1,69 @@ +/** + * One-shot setup: ensures the Arthur prompt-host task exists and has the three + * workflow prompts seeded with the `production` tag. + * + * npx tsx scripts/setup-arthur-prompts.ts + * + * Requires in .env: + * GENAI_ENGINE_API_KEY + * GENAI_ENGINE_TRACE_ENDPOINT + * + * Prints the task UUID as a paste-ready env line at the end. + */ +import "dotenv/config"; +import { ArthurClient } from "../src/sandbox/arthur-client.js"; +import { PROMPT_FALLBACKS, PROMPT_NAMES } from "../src/lib/prompts.js"; + +const TASK_NAME = "ai-workflow-prompts"; +const TAG = "production"; + +const apiKey = process.env.GENAI_ENGINE_API_KEY; +const endpoint = process.env.GENAI_ENGINE_TRACE_ENDPOINT; +if (!apiKey || !endpoint) { + console.error("Missing GENAI_ENGINE_{API_KEY,TRACE_ENDPOINT} in env/.env"); + process.exit(1); +} + +const modelName = process.env.CLAUDE_MODEL ?? "claude-opus-4-6"; +const client = ArthurClient.fromTraceEndpoint(endpoint, apiKey); + +async function main() { + let task = await client.findTaskByName(TASK_NAME); + if (task) { + console.log(`Found existing task "${TASK_NAME}" (${task.id}) — will overwrite prompts.`); + } else { + task = await client.createPlainTask(TASK_NAME); + console.log(`Created new task "${TASK_NAME}" (${task.id}).`); + } + + const failures: string[] = []; + for (const name of PROMPT_NAMES) { + const body = PROMPT_FALLBACKS[name]; + console.log(`\n seeding ${name}…`); + try { + const created = await client.createPromptVersion(task.id, name, body, { modelName }); + const version = created.version; + if (version === undefined) { + console.error(` no version returned; cannot tag. full response:`, created); + failures.push(name); + continue; + } + await client.tagPromptVersion(task.id, name, version, TAG); + console.log(` ✓ version ${version} tagged "${TAG}"`); + } catch (err) { + console.error(` failed to seed "${name}":`, err instanceof Error ? err.message : err); + failures.push(name); + } + } + + if (failures.length > 0) { + console.error( + `\nSetup FAILED for task ${task.id}. Affected prompts: ${failures.join(", ")}`, + ); + process.exit(1); + } + + console.log(`\nSetup complete. Add this to .env:\n GENAI_ENGINE_PROMPT_TASK_ID=${task.id}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/test-arthur-ensure.ts b/scripts/test-arthur-ensure.ts new file mode 100644 index 0000000..3d46c54 --- /dev/null +++ b/scripts/test-arthur-ensure.ts @@ -0,0 +1,30 @@ +/** + * Smoke test for ArthurClient.ensureTaskForTicket against a live engine. + * npx tsx scripts/test-arthur-ensure.ts + */ +import "dotenv/config"; +import { ArthurClient } from "../src/sandbox/arthur-client.js"; + +async function main() { + const identifier = process.argv[2]; + if (!identifier) { + console.error("Usage: npx tsx scripts/test-arthur-ensure.ts "); + process.exit(1); + } + const apiKey = process.env.GENAI_ENGINE_API_KEY; + const endpoint = process.env.GENAI_ENGINE_TRACE_ENDPOINT; + if (!apiKey || !endpoint) { + console.error("Missing GENAI_ENGINE_API_KEY / GENAI_ENGINE_TRACE_ENDPOINT"); + process.exit(1); + } + + const client = ArthurClient.fromTraceEndpoint(endpoint, apiKey); + const existing = await client.findTicketTasks(identifier); + console.log(`Existing tasks matching "${identifier}(.N)?":`); + for (const t of existing) console.log(` ${t.id} ${t.name}`); + + const task = await client.ensureTaskForTicket(identifier); + console.log(`\nCreated: ${task.id} name="${task.name}"`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/test-arthur-sandbox.ts b/scripts/test-arthur-sandbox.ts new file mode 100644 index 0000000..877b9f8 --- /dev/null +++ b/scripts/test-arthur-sandbox.ts @@ -0,0 +1,102 @@ +/** + * Diagnostic: provision a bare sandbox, install the Arthur tracer the same way + * SandboxManager does, and inspect what actually landed. + * + * Usage: + * pnpm build:arthur-tracer + * npx tsx scripts/test-arthur-sandbox.ts + * + * Reads GENAI_ENGINE_API_KEY / TASK_ID / TRACE_ENDPOINT from env (.env). + */ + +import "dotenv/config"; +import { Sandbox } from "@vercel/sandbox"; +import { ARTHUR_TRACER_PY_BASE64 } from "../src/sandbox/arthur-tracer.js"; + +async function run(sandbox: Awaited>, label: string, cmd: string, args: string[]) { + const r = await sandbox.runCommand(cmd, args); + const stdout = (await r.stdout()).trim(); + const stderr = (await r.stderr()).trim(); + console.log(`--- ${label} (exit=${r.exitCode}) ---`); + if (stdout) console.log("stdout:", stdout.slice(0, 1200)); + if (stderr) console.log("stderr:", stderr.slice(0, 1200)); + console.log(); + return r; +} + +async function main() { + const apiKey = process.env.GENAI_ENGINE_API_KEY; + const taskId = process.env.GENAI_ENGINE_TASK_ID; + const endpoint = process.env.GENAI_ENGINE_TRACE_ENDPOINT; + if (!apiKey || !taskId || !endpoint) { + console.error("Missing GENAI_ENGINE_{API_KEY,TASK_ID,TRACE_ENDPOINT} in env/.env"); + process.exit(1); + } + + console.log("=== Provisioning sandbox (node24) ===\n"); + const sandbox = await Sandbox.create({ runtime: "node24", timeout: 300_000 }); + console.log(`sandboxId=${sandbox.sandboxId}\n`); + + try { + await run(sandbox, "which python3", "bash", ["-c", "command -v python3 || echo MISSING"]); + await run(sandbox, "python3 --version", "bash", ["-c", "python3 --version 2>&1 || echo MISSING"]); + await run(sandbox, "which pip3", "bash", ["-c", "command -v pip3 || echo MISSING"]); + await run(sandbox, "pip3 --version", "bash", ["-c", "pip3 --version 2>&1 || echo MISSING"]); + + console.log("=== pip bootstrap + install (same command as installArthurTracer) ===\n"); + await run(sandbox, "ensurepip + pip install", "bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0' 2>&1", + ]); + + await run(sandbox, "python3 -c import otel", "bash", [ + "-c", + "python3 -c 'import opentelemetry.sdk, opentelemetry.exporter.otlp.proto.http.trace_exporter; print(\"OK\")' 2>&1", + ]); + + console.log("=== Writing tracer + config ===\n"); + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([{ path: "/tmp/arthur-tracer.py", content: tracerBytes }]); + await run(sandbox, "mv tracer", "bash", [ + "-c", + "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py && ls -l $HOME/.claude/hooks/", + ]); + + const configJson = JSON.stringify({ api_key: apiKey, task_id: taskId, endpoint }, null, 2); + await sandbox.writeFiles([{ path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }]); + await run(sandbox, "mv config", "bash", [ + "-c", + "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json && ls -l $HOME/.claude/arthur_config.json", + ]); + + console.log("=== Dry-run the tracer directly with a synthetic UserPromptSubmit payload ===\n"); + // Feed the tracer a minimal hook payload so it attempts to build+send a trace. + await run(sandbox, "tracer user_prompt_submit", "bash", [ + "-c", + `cat <<'JSON' | python3 $HOME/.claude/hooks/claude_code_tracer.py user_prompt_submit 2>&1 +{"session_id":"diag-session","prompt":"hello from diagnostic","cwd":"/tmp"} +JSON`, + ]); + + console.log("=== Check the tracer's own log (if any) ===\n"); + await run(sandbox, "ls ~/.claude", "bash", ["-c", "ls -la $HOME/.claude/ 2>&1 || true"]); + await run(sandbox, "tracer log tail", "bash", [ + "-c", + "find $HOME/.claude -maxdepth 3 -name '*.log' -o -name 'trace*' 2>/dev/null | head -20 && echo --- && for f in $(find $HOME/.claude -maxdepth 3 -name '*.log' 2>/dev/null); do echo \">>> $f\"; tail -n 40 $f; done", + ]); + + console.log("=== Curl the endpoint from inside the sandbox ===\n"); + await run(sandbox, "curl endpoint", "bash", [ + "-c", + `curl -sS -o /tmp/curl.out -w 'HTTP %{http_code} time=%{time_total}s\\n' -X POST '${endpoint}' -H 'Content-Type: application/x-protobuf' -H 'Authorization: Bearer ${apiKey}' -H 'ngrok-skip-browser-warning: true' --data-binary '' --max-time 10; echo '---'; head -c 400 /tmp/curl.out`, + ]); + } finally { + console.log("\n=== Stopping sandbox ==="); + await sandbox.stop().catch(() => {}); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 1a13c49..0ef7c89 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -211,6 +211,16 @@ Return a JSON object with: - \`issues\`: Array of issues found — each with \`file\`, \`description\`, \`severity\` ("critical" or "suggestion"). Include both fixed and unfixable issues. - \`error\`: Failure details (when failed).`; +export const PROMPT_NAMES = ["research-plan", "implement", "review"] as const; +export type PromptName = typeof PROMPT_NAMES[number]; + +/** Fallback strings keyed by Arthur prompt name (no `.md` suffix). */ +export const PROMPT_FALLBACKS: Record = { + "research-plan": researchPlanPrompt, + "implement": implementPrompt, + "review": reviewPrompt, +}; + const prompts: Record = { "research-plan.md": researchPlanPrompt, "implement.md": implementPrompt, diff --git a/src/sandbox/arthur-client.test.ts b/src/sandbox/arthur-client.test.ts new file mode 100644 index 0000000..a5e4a80 --- /dev/null +++ b/src/sandbox/arthur-client.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ArthurClient } from "./arthur-client.js"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("ArthurClient", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + describe("fromTraceEndpoint", () => { + it("strips /api/v1/traces from the endpoint", () => { + const c = ArthurClient.fromTraceEndpoint("https://host.example/api/v1/traces", "k"); + expect((c as unknown as { baseUrl: string }).baseUrl).toBe("https://host.example"); + }); + + it("handles trailing slash", () => { + const c = ArthurClient.fromTraceEndpoint("https://host.example/api/v1/traces/", "k"); + expect((c as unknown as { baseUrl: string }).baseUrl).toBe("https://host.example"); + }); + }); + + describe("findTicketTasks", () => { + it("filters substring matches to exact prefix or prefix.N", async () => { + // Arthur search is substring-based: "AWT-1" matches AWT-1, AWT-10, AWT-1.1, AWT-100, AWT-123 + mockFetch.mockResolvedValueOnce(jsonResponse({ + count: 5, + tasks: [ + { id: "a", name: "AWT-1" }, + { id: "b", name: "AWT-10" }, + { id: "c", name: "AWT-1.1" }, + { id: "d", name: "AWT-1.2" }, + { id: "e", name: "AWT-100" }, + ], + })); + + const client = new ArthurClient("http://host", "k"); + const result = await client.findTicketTasks("AWT-1"); + expect(result.map((t) => t.name)).toEqual(["AWT-1", "AWT-1.1", "AWT-1.2"]); + }); + + it("excludes archived tasks", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + count: 2, + tasks: [ + { id: "a", name: "AWT-42", is_archived: true }, + { id: "b", name: "AWT-42.1", is_archived: false }, + ], + })); + + const client = new ArthurClient("http://host", "k"); + const result = await client.findTicketTasks("AWT-42"); + expect(result.map((t) => t.id)).toEqual(["b"]); + }); + + it("sends auth header and correct body", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ count: 0, tasks: [] })); + const client = new ArthurClient("http://host", "secret"); + await client.findTicketTasks("AWT-1"); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v2/tasks/search"); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("Bearer secret"); + expect(JSON.parse(init.body)).toEqual({ task_name: "AWT-1" }); + }); + }); + + describe("ensureTaskForTicket", () => { + it("first run → creates task with exact identifier", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ count: 0, tasks: [] })) // search + .mockResolvedValueOnce(jsonResponse({ id: "new-id", name: "AWT-42" })); // create + + const client = new ArthurClient("http://host", "k"); + const task = await client.ensureTaskForTicket("AWT-42"); + + expect(task).toEqual({ id: "new-id", name: "AWT-42" }); + const createCall = mockFetch.mock.calls[1]; + expect(JSON.parse(createCall[1].body)).toEqual({ name: "AWT-42", is_agentic: true }); + }); + + it("second run → creates AWT-42.1", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ count: 1, tasks: [{ id: "a", name: "AWT-42" }] })) + .mockResolvedValueOnce(jsonResponse({ id: "new", name: "AWT-42.1" })); + + const client = new ArthurClient("http://host", "k"); + const task = await client.ensureTaskForTicket("AWT-42"); + + expect(task.name).toBe("AWT-42.1"); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).name).toBe("AWT-42.1"); + }); + + it("third run → creates AWT-42.2", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ + count: 2, + tasks: [ + { id: "a", name: "AWT-42" }, + { id: "b", name: "AWT-42.1" }, + ], + })) + .mockResolvedValueOnce(jsonResponse({ id: "new", name: "AWT-42.2" })); + + const client = new ArthurClient("http://host", "k"); + const task = await client.ensureTaskForTicket("AWT-42"); + + expect(task.name).toBe("AWT-42.2"); + }); + + it("sparse suffixes → uses max+1 (AWT-42 + AWT-42.2 → AWT-42.3)", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ + count: 2, + tasks: [ + { id: "a", name: "AWT-42" }, + { id: "b", name: "AWT-42.2" }, + ], + })) + .mockResolvedValueOnce(jsonResponse({ id: "new", name: "AWT-42.3" })); + + const client = new ArthurClient("http://host", "k"); + const task = await client.ensureTaskForTicket("AWT-42"); + + expect(task.name).toBe("AWT-42.3"); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).name).toBe("AWT-42.3"); + }); + + it("does not collide with AWT-420 when resolving AWT-42", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ + count: 3, + tasks: [ + { id: "a", name: "AWT-42" }, + { id: "b", name: "AWT-420" }, + { id: "c", name: "AWT-421" }, + ], + })) + .mockResolvedValueOnce(jsonResponse({ id: "new", name: "AWT-42.1" })); + + const client = new ArthurClient("http://host", "k"); + const task = await client.ensureTaskForTicket("AWT-42"); + + expect(task.name).toBe("AWT-42.1"); // only AWT-42 counted, not AWT-420/AWT-421 + }); + }); + + describe("error handling", () => { + it("throws on non-2xx responses", async () => { + mockFetch.mockResolvedValueOnce( + new Response("nope", { status: 401 }), + ); + + const client = new ArthurClient("http://host", "k"); + await expect(client.findTicketTasks("AWT-1")).rejects.toThrow(/401/); + }); + }); + + describe("findTaskByName", () => { + it("returns exact-name match, excluding archived", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + count: 3, + tasks: [ + { id: "a", name: "ai-workflow-prompts" }, + { id: "b", name: "ai-workflow-prompts-old", is_archived: true }, + { id: "c", name: "ai-workflow-prompts", is_archived: true }, + ], + })); + const client = new ArthurClient("http://host", "k"); + const t = await client.findTaskByName("ai-workflow-prompts"); + expect(t?.id).toBe("a"); + }); + + it("returns null on no match", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ count: 0, tasks: [] })); + const client = new ArthurClient("http://host", "k"); + expect(await client.findTaskByName("nothing")).toBeNull(); + }); + }); + + describe("prompts", () => { + it("getPromptByTag returns messages[0].content on 200", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + name: "research-plan", + version: 3, + messages: [{ role: "user", content: "the prompt body" }], + })); + const client = new ArthurClient("http://host", "k"); + const body = await client.getPromptByTag("task-uuid", "research-plan", "production"); + expect(body).toBe("the prompt body"); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/task-uuid/prompts/research-plan/versions/tags/production"); + }); + + it("getPromptByTag returns null on 404", async () => { + mockFetch.mockResolvedValueOnce(new Response("not found", { status: 404 })); + const client = new ArthurClient("http://host", "k"); + expect(await client.getPromptByTag("t", "research-plan", "production")).toBeNull(); + }); + + it("createPromptVersion POSTs single-message body with user role", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ + name: "implement", + version: 5, + messages: [{ role: "user", content: "x" }], + })); + const client = new ArthurClient("http://host", "k"); + const result = await client.createPromptVersion("task-uuid", "implement", "x"); + expect(result.version).toBe(5); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/task-uuid/prompts/implement"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body.messages).toEqual([{ role: "user", content: "x" }]); + }); + + it("tagPromptVersion PUTs the tag", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ name: "review", version: 2, messages: [] })); + const client = new ArthurClient("http://host", "k"); + await client.tagPromptVersion("t", "review", 2, "production"); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://host/api/v1/tasks/t/prompts/review/versions/2/tags"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body)).toEqual({ tag: "production" }); + }); + + it("getPromptByTag throws on non-404 non-2xx (5xx)", async () => { + mockFetch.mockResolvedValueOnce(new Response("boom", { status: 500 })); + const client = new ArthurClient("http://host", "k"); + await expect(client.getPromptByTag("t", "x", "production")).rejects.toThrow(/500/); + }); + }); +}); diff --git a/src/sandbox/arthur-client.ts b/src/sandbox/arthur-client.ts new file mode 100644 index 0000000..8afc77c --- /dev/null +++ b/src/sandbox/arthur-client.ts @@ -0,0 +1,174 @@ +/** + * Minimal client for the Arthur GenAI Engine tasks API. + * + * Used by the workflow to auto-create a per-ticket Arthur task so every run + * gets its own observability bucket. Re-runs of the same ticket get a + * `.1`, `.2`, … suffix. + */ + +export interface ArthurTask { + id: string; + name: string; + is_archived?: boolean; +} + +export interface AgenticPrompt { + name: string; + version?: number | string; + messages: Array<{ role: string; content: string }>; +} + +interface SearchResponse { + count: number; + tasks: ArthurTask[]; +} + +export class ArthurClient { + constructor( + private readonly baseUrl: string, + private readonly apiKey: string, + ) {} + + /** + * Derive the Arthur base URL from the full traces endpoint by stripping + * `/api/v1/traces` (the tracer writes the full path; the tasks API lives + * under the same host at `/api/v2/tasks`). + */ + static fromTraceEndpoint(endpoint: string, apiKey: string): ArthurClient { + const base = endpoint.replace(/\/api\/v1\/traces\/?$/, "").replace(/\/+$/, ""); + return new ArthurClient(base, apiKey); + } + + private async request(path: string, init: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "ngrok-skip-browser-warning": "true", + ...(init.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Arthur ${init.method ?? "GET"} ${path} → ${res.status}: ${body.slice(0, 300)}`); + } + return (await res.json()) as T; + } + + /** + * Return tasks whose name equals `prefix` or matches `^prefix\.\d+$`. + * Arthur's `task_name` search is substring-based, so we post-filter to + * avoid `AWT-1` catching `AWT-10`. + */ + async findTicketTasks(prefix: string): Promise { + const { tasks } = await this.request("/api/v2/tasks/search", { + method: "POST", + body: JSON.stringify({ task_name: prefix }), + }); + const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped}(\\.\\d+)?$`); + return tasks.filter((t) => re.test(t.name) && !t.is_archived); + } + + async createTask(name: string): Promise { + return this.request("/api/v2/tasks", { + method: "POST", + body: JSON.stringify({ name, is_agentic: true }), + }); + } + + /** + * Resolve-or-create a task for a ticket identifier. + * first run: "AWT-42" + * re-runs: "AWT-42.1", "AWT-42.2", ... + * + * Uses max(existing suffix) + 1 so sparse histories (e.g. AWT-42.2 present + * without AWT-42.1) don't collide with an existing name. + */ + async ensureTaskForTicket(identifier: string): Promise { + const existing = await this.findTicketTasks(identifier); + if (existing.length === 0) return this.createTask(identifier); + const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const suffixRe = new RegExp(`^${escaped}\\.(\\d+)$`); + let max = 0; + for (const t of existing) { + const m = t.name.match(suffixRe); + if (m) max = Math.max(max, parseInt(m[1], 10)); + } + return this.createTask(`${identifier}.${max + 1}`); + } + + /** Exact-name lookup. Returns the task if found (non-archived), else null. */ + async findTaskByName(name: string): Promise { + const { tasks } = await this.request<{ count: number; tasks: ArthurTask[] }>( + "/api/v2/tasks/search", + { method: "POST", body: JSON.stringify({ task_name: name }) }, + ); + return tasks.find((t) => t.name === name && !t.is_archived) ?? null; + } + + /** + * Create a task for non-ticket purposes (e.g. the shared prompt-host task). + * Body is identical to `createTask` today — kept as a separate method so + * callers signal intent and so the two paths can diverge later without + * touching each other's call sites. + */ + async createPlainTask(name: string): Promise { + return this.request("/api/v2/tasks", { + method: "POST", + body: JSON.stringify({ name, is_agentic: true }), + }); + } + + /** Fetch a tagged prompt version. Returns the first message's content, or null if 404. */ + async getPromptByTag(taskId: string, name: string, tag: string): Promise { + const path = `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}/versions/tags/${encodeURIComponent(tag)}`; + const res = await fetch(`${this.baseUrl}${path}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "ngrok-skip-browser-warning": "true", + }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Arthur GET ${path} → ${res.status}: ${body.slice(0, 300)}`); + } + const prompt = (await res.json()) as AgenticPrompt; + const first = prompt.messages?.[0]; + return first?.content ?? null; + } + + /** Create a new version of a named prompt on a task. Content is sent as a single user message. */ + async createPromptVersion( + taskId: string, + name: string, + content: string, + opts: { modelName?: string; modelProvider?: string } = {}, + ): Promise { + return this.request( + `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}`, + { + method: "POST", + body: JSON.stringify({ + messages: [{ role: "user", content }], + model_name: opts.modelName ?? "claude-opus-4-6", + model_provider: opts.modelProvider ?? "anthropic", + }), + }, + ); + } + + /** Add a tag (e.g. "production") to a specific version. */ + async tagPromptVersion(taskId: string, name: string, version: number | string, tag: string): Promise { + await this.request( + `/api/v1/tasks/${encodeURIComponent(taskId)}/prompts/${encodeURIComponent(name)}/versions/${encodeURIComponent(String(version))}/tags`, + { + method: "PUT", + body: JSON.stringify({ tag }), + }, + ); + } +} diff --git a/src/sandbox/arthur-tracer.ts b/src/sandbox/arthur-tracer.ts new file mode 100644 index 0000000..c1dc6cf --- /dev/null +++ b/src/sandbox/arthur-tracer.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED — do not edit by hand. +// Source: ../arthur-engine/integrations/claude-code/claude_code_tracer.py +// Regenerate: pnpm build:arthur-tracer +// +// Base64-encoded Python source of the Arthur Engine Claude Code tracer. +// Bundled so Nitro reliably ships it with the Vercel deployment; decoded at +// runtime and written into each provisioned sandbox under ~/.claude/hooks/. +export const ARTHUR_TRACER_PY_BASE64 = "IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwoiIiIKY2xhdWRlX2NvZGVfdHJhY2VyLnB5IOKAlCBPcGVuSW5mZXJlbmNlIHRyYWNlciBmb3IgQ2xhdWRlIENvZGUgaG9va3MuCgpTaGlwcyBDbGF1ZGUgQ29kZSBhY3Rpdml0aWVzIGFzIE9wZW5JbmZlcmVuY2UgdHJhY2VzIHRvIEFydGh1ciBFbmdpbmUuCgpUcmFjZSBtb2RlbDoKICAtIEVhY2ggdXNlciBwcm9tcHQg4oaSIG9uZSB0cmFjZSAoQ0hBSU4gcm9vdCArIFRPT0wvUkVUUklFVkVSL0FHRU5UIGNoaWxkcmVuICsgTExNIGNoaWxkcmVuKQogIC0gRXhpdGluZyB0aGUgc2Vzc2lvbiBjb21wbGV0ZXMgdGhlIGN1cnJlbnQgaW4tcHJvZ3Jlc3MgdHJhY2UKICAtIFRyYWNlcyBhcmUgbGlua2VkIGJ5IGFydGh1ci5zZXNzaW9uIGF0dHJpYnV0ZQoKSG9vayBldmVudHM6CiAgdXNlcl9wcm9tcHRfc3VibWl0IOKAlCBzdGFydCBhIG5ldyB0cmFjZTsgY29tcGxldGUgcHJldmlvdXMgdHJhY2UgaWYgaW4gcHJvZ3Jlc3MKICBwcmVfdG9vbCAgICAgICAgICAg4oCUIHJlY29yZCBjdXJyZW50IHRvb2wgc3RhcnQgKGZhbGxiYWNrIHRyYWNlIGNyZWF0aW9uIGlmIFVzZXJQcm9tcHRTdWJtaXQgdW5hdmFpbGFibGUpCiAgcG9zdF90b29sICAgICAgICAgIOKAlCBzZW5kIGEgVE9PTC9SRVRSSUVWRVIvQUdFTlQgc3BhbiBmb3IgdGhlIGNvbXBsZXRlZCB0b29sIGNhbGwKICBwb3N0X3Rvb2xfZmFpbHVyZSAg4oCUIHNlbmQgYW4gZXJyb3IgVE9PTC9SRVRSSUVWRVIvQUdFTlQgc3BhbiBmb3IgYSBmYWlsZWQgdG9vbCBjYWxsCiAgc3RvcCAgICAgICAgICAgICAgIOKAlCBjb21wbGV0ZSB0aGUgY3VycmVudCB0cmFjZSBhbmQgY2xlYW4gdXAgc2Vzc2lvbiBzdGF0ZQoKQ29uZmlnIHByaW9yaXR5IChoaWdoZXN0IGZpcnN0KToKICAxLiBFbnYgdmFyczogR0VOQUlfRU5HSU5FX0FQSV9LRVksIEdFTkFJX0VOR0lORV9UQVNLX0lELCBHRU5BSV9FTkdJTkVfVFJBQ0VfRU5EUE9JTlQKICAyLiBQcm9qZWN0IGNvbmZpZzogJENMQVVERV9QUk9KRUNUX0RJUi8uY2xhdWRlL2FydGh1cl9jb25maWcuanNvbgogIDMuIEdsb2JhbCBjb25maWc6IH4vLmNsYXVkZS9hcnRodXJfY29uZmlnLmpzb24KICA0LiBTaWxlbnQgbm8tb3AgaWYgbm90aGluZyBjb25maWd1cmVkCiIiIgoKaW1wb3J0IGNvbnRleHRsaWIKaW1wb3J0IGZjbnRsCmltcG9ydCBqc29uCmltcG9ydCBsb2dnaW5nCmltcG9ydCBvcwppbXBvcnQgc3lzCmltcG9ydCB0aW1lCmZyb20gZGF0ZXRpbWUgaW1wb3J0IGRhdGV0aW1lCmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aApmcm9tIHR5cGluZyBpbXBvcnQgQW55LCBPcHRpb25hbAoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBMb2dnaW5nIOKAlCBzdGRlcnIgb25seSBzbyBzdGRvdXQgc3RheXMgY2xlYW4gZm9yIENsYXVkZSBob29rcwojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpsb2dnaW5nLmJhc2ljQ29uZmlnKAogICAgbGV2ZWw9bG9nZ2luZy5XQVJOSU5HLAogICAgZm9ybWF0PSJbY2xhdWRlX2NvZGVfdHJhY2VyXSAlKGxldmVsbmFtZSlzOiAlKG1lc3NhZ2UpcyIsCiAgICBzdHJlYW09c3lzLnN0ZGVyciwKKQpsb2cgPSBsb2dnaW5nLmdldExvZ2dlcigiY2xhdWRlX2NvZGVfdHJhY2VyIikKCgojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQojIENvbmZpZyBkaXNjb3ZlcnkKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KCgpkZWYgX2xvYWRfY29uZmlnX2ZpbGUocGF0aDogUGF0aCkgLT4gZGljdDoKICAgIHRyeToKICAgICAgICBpZiBwYXRoLmV4aXN0cygpOgogICAgICAgICAgICByZXR1cm4ganNvbi5sb2FkcyhwYXRoLnJlYWRfdGV4dCgpKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIGxvZy5kZWJ1ZygiRmFpbGVkIHRvIHJlYWQgY29uZmlnICVzOiAlcyIsIHBhdGgsIGUpCiAgICByZXR1cm4ge30KCgpkZWYgZGlzY292ZXJfY29uZmlnKCkgLT4gT3B0aW9uYWxbZGljdF06CiAgICAiIiJSZXR1cm5zIGNvbmZpZyBkaWN0IHdpdGgga2V5czogYXBpX2tleSwgdGFza19pZCwgZW5kcG9pbnQuCiAgICBSZXR1cm5zIE5vbmUgaWYgbm90IGNvbmZpZ3VyZWQgKHNpbGVudCBuby1vcCkuIiIiCiAgICBhcGlfa2V5ID0gb3MuZW52aXJvbi5nZXQoIkdFTkFJX0VOR0lORV9BUElfS0VZIiwgIiIpCiAgICB0YXNrX2lkID0gb3MuZW52aXJvbi5nZXQoIkdFTkFJX0VOR0lORV9UQVNLX0lEIiwgIiIpCiAgICBlbmRwb2ludCA9IG9zLmVudmlyb24uZ2V0KCJHRU5BSV9FTkdJTkVfVFJBQ0VfRU5EUE9JTlQiLCAiIikKCiAgICBpZiBub3QgKGFwaV9rZXkgYW5kIHRhc2tfaWQgYW5kIGVuZHBvaW50KToKICAgICAgICBwcm9qZWN0X2RpciA9IG9zLmVudmlyb24uZ2V0KCJDTEFVREVfUFJPSkVDVF9ESVIiLCBvcy5nZXRjd2QoKSkKICAgICAgICBwcm9qZWN0X2NmZyA9IF9sb2FkX2NvbmZpZ19maWxlKAogICAgICAgICAgICBQYXRoKHByb2plY3RfZGlyKSAvICIuY2xhdWRlIiAvICJhcnRodXJfY29uZmlnLmpzb24iLAogICAgICAgICkKICAgICAgICBhcGlfa2V5ID0gYXBpX2tleSBvciBwcm9qZWN0X2NmZy5nZXQoImFwaV9rZXkiLCAiIikKICAgICAgICB0YXNrX2lkID0gdGFza19pZCBvciBwcm9qZWN0X2NmZy5nZXQoInRhc2tfaWQiLCAiIikKICAgICAgICBlbmRwb2ludCA9IGVuZHBvaW50IG9yIHByb2plY3RfY2ZnLmdldCgiZW5kcG9pbnQiLCAiIikKCiAgICBpZiBub3QgKGFwaV9rZXkgYW5kIHRhc2tfaWQgYW5kIGVuZHBvaW50KToKICAgICAgICBnbG9iYWxfY2ZnID0gX2xvYWRfY29uZmlnX2ZpbGUoUGF0aC5ob21lKCkgLyAiLmNsYXVkZSIgLyAiYXJ0aHVyX2NvbmZpZy5qc29uIikKICAgICAgICBhcGlfa2V5ID0gYXBpX2tleSBvciBnbG9iYWxfY2ZnLmdldCgiYXBpX2tleSIsICIiKQogICAgICAgIHRhc2tfaWQgPSB0YXNrX2lkIG9yIGdsb2JhbF9jZmcuZ2V0KCJ0YXNrX2lkIiwgIiIpCiAgICAgICAgZW5kcG9pbnQgPSBlbmRwb2ludCBvciBnbG9iYWxfY2ZnLmdldCgiZW5kcG9pbnQiLCAiIikKCiAgICBpZiBub3QgKGFwaV9rZXkgYW5kIHRhc2tfaWQgYW5kIGVuZHBvaW50KToKICAgICAgICByZXR1cm4gTm9uZQoKICAgIHJldHVybiB7ImFwaV9rZXkiOiBhcGlfa2V5LCAidGFza19pZCI6IHRhc2tfaWQsICJlbmRwb2ludCI6IGVuZHBvaW50fQoKCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiMgU3RhdGUgZmlsZSBoZWxwZXJzCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgpTVEFURV9ESVIgPSBQYXRoLmhvbWUoKSAvICIuY2xhdWRlIiAvICJ0cmFjZXIiClNUQVRFX01BWF9BR0VfUyA9IDQ4ICogMzYwMAoKCmRlZiBfc3RhdGVfcGF0aChzZXNzaW9uX2lkOiBzdHIpIC0+IFBhdGg6CiAgICBTVEFURV9ESVIubWtkaXIocGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQogICAgcGF0aCA9IChTVEFURV9ESVIgLyBmIntzZXNzaW9uX2lkfS5qc29uIikucmVzb2x2ZSgpCiAgICBpZiBub3QgcGF0aC5pc19yZWxhdGl2ZV90byhTVEFURV9ESVIucmVzb2x2ZSgpKToKICAgICAgICByYWlzZSBWYWx1ZUVycm9yKGYiSW52YWxpZCBzZXNzaW9uX2lkOiB7c2Vzc2lvbl9pZCFyfSIpCiAgICByZXR1cm4gcGF0aAoKCmRlZiBfbG9hZF9zdGF0ZShzZXNzaW9uX2lkOiBzdHIpIC0+IGRpY3Q6CiAgICBwYXRoID0gX3N0YXRlX3BhdGgoc2Vzc2lvbl9pZCkKICAgIHRyeToKICAgICAgICBpZiBwYXRoLmV4aXN0cygpOgogICAgICAgICAgICByZXR1cm4ganNvbi5sb2FkcyhwYXRoLnJlYWRfdGV4dCgpKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIGxvZy5kZWJ1ZygiRmFpbGVkIHRvIGxvYWQgc3RhdGUgZm9yICVzOiAlcyIsIHNlc3Npb25faWQsIGUpCiAgICByZXR1cm4ge30KCgpkZWYgX3NhdmVfc3RhdGUoc2Vzc2lvbl9pZDogc3RyLCBzdGF0ZTogZGljdCkgLT4gTm9uZToKICAgIHRyeToKICAgICAgICBfc3RhdGVfcGF0aChzZXNzaW9uX2lkKS53cml0ZV90ZXh0KGpzb24uZHVtcHMoc3RhdGUpKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIGxvZy53YXJuaW5nKCJGYWlsZWQgdG8gc2F2ZSBzdGF0ZSBmb3IgJXM6ICVzIiwgc2Vzc2lvbl9pZCwgZSkKCgpkZWYgX2RlbGV0ZV9zdGF0ZShzZXNzaW9uX2lkOiBzdHIpIC0+IE5vbmU6CiAgICB0cnk6CiAgICAgICAgcCA9IF9zdGF0ZV9wYXRoKHNlc3Npb25faWQpCiAgICAgICAgaWYgcC5leGlzdHMoKToKICAgICAgICAgICAgcC51bmxpbmsoKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIGxvZy5kZWJ1ZygiRmFpbGVkIHRvIGRlbGV0ZSBzdGF0ZSBmb3IgJXM6ICVzIiwgc2Vzc2lvbl9pZCwgZSkKCgpkZWYgX2NsZWFudXBfc3RhbGVfc3RhdGVzKCkgLT4gTm9uZToKICAgIHRyeToKICAgICAgICBub3cgPSB0aW1lLnRpbWUoKQogICAgICAgIGZvciBwIGluIFNUQVRFX0RJUi5nbG9iKCIqLmpzb24iKToKICAgICAgICAgICAgaWYgbm93IC0gcC5zdGF0KCkuc3RfbXRpbWUgPiBTVEFURV9NQVhfQUdFX1M6CiAgICAgICAgICAgICAgICBwLnVubGluaygpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgbG9nLmRlYnVnKCJTdGFsZSBzdGF0ZSBjbGVhbnVwIGZhaWxlZDogJXMiLCBlKQoKCmRlZiBfbmV3X3RyYWNlX2lkKCkgLT4gc3RyOgogICAgaW1wb3J0IHNlY3JldHMKCiAgICByZXR1cm4gc2VjcmV0cy50b2tlbl9oZXgoMTYpCgoKZGVmIF9uZXdfc3Bhbl9pZCgpIC0+IHN0cjoKICAgIGltcG9ydCBzZWNyZXRzCgogICAgcmV0dXJuIHNlY3JldHMudG9rZW5faGV4KDgpCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBTdWJhZ2VudCB0cmFjZS1jb250ZXh0IHByb3BhZ2F0aW9uCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgojIEhvdyBsb25nIHRvIHdhaXQgZm9yIGEgc3ViYWdlbnQgdG8gY2xhaW0gdGhlIGNvbnRleHQgZmlsZSBiZWZvcmUgZ2l2aW5nIHVwLgpfUEVORElOR19BR0VOVF9USU1FT1VUX1MgPSAzMApfUEVORElOR19BR0VOVF9ESVIgPSBTVEFURV9ESVIgLyAicGVuZGluZ19hZ2VudCIKCgpkZWYgX3dyaXRlX3BlbmRpbmdfYWdlbnRfY29udGV4dCgKICAgIHBhcmVudF9zZXNzaW9uX2lkOiBzdHIsCiAgICBwYXJlbnRfdHJhY2VfaWQ6IHN0ciwKICAgIHBhcmVudF9zcGFuX2lkOiBzdHIsCiAgICBhZ2VudF9wcm9tcHQ6IHN0ciA9ICIiLAopIC0+IE5vbmU6CiAgICAiIiJXcml0ZSBhIHNpZGUtY2hhbm5lbCBmaWxlIHNvIHRoZSBuZXh0IHN1YmFnZW50IGNhbiBpbmhlcml0IHRoaXMgdHJhY2UuCgogICAgQ2FsbGVkIGZyb20gaGFuZGxlX3ByZV90b29sIHdoZW4gYW4gQWdlbnQgdG9vbCBpbnZvY2F0aW9uIGlzIGRldGVjdGVkLgogICAgVGhlIGZpbGUgaXMga2V5ZWQgYnkgc2Vzc2lvbitzcGFuIHNvIHBhcmFsbGVsIGFnZW50IGludm9jYXRpb25zIGluIHRoZQogICAgc2FtZSBzZXNzaW9uIGVhY2ggZ2V0IHRoZWlyIG93biBmaWxlLiAgYGBhZ2VudF9wcm9tcHRgYCBpcyBzdG9yZWQgc28gdGhhdAogICAgYGBfY2xhaW1fcGVuZGluZ19hZ2VudF9jb250ZXh0YGAgY2FuIG1hdGNoIHRoZSBleGFjdCBjb250ZXh0IGZvciBhIGdpdmVuCiAgICBzdWJhZ2VudCB3aGVuIG11bHRpcGxlIGFnZW50cyBhcmUgcnVubmluZyBpbiBwYXJhbGxlbC4KICAgICIiIgogICAgX1BFTkRJTkdfQUdFTlRfRElSLm1rZGlyKHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkKICAgIHBhdGggPSBfUEVORElOR19BR0VOVF9ESVIgLyBmIntwYXJlbnRfc2Vzc2lvbl9pZH1fe3BhcmVudF9zcGFuX2lkfS5qc29uIgogICAgcGF0aC53cml0ZV90ZXh0KAogICAgICAgIGpzb24uZHVtcHMoCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICJwYXJlbnRfc2Vzc2lvbl9pZCI6IHBhcmVudF9zZXNzaW9uX2lkLAogICAgICAgICAgICAgICAgInBhcmVudF90cmFjZV9pZCI6IHBhcmVudF90cmFjZV9pZCwKICAgICAgICAgICAgICAgICJwYXJlbnRfc3Bhbl9pZCI6IHBhcmVudF9zcGFuX2lkLAogICAgICAgICAgICAgICAgImNyZWF0ZWRfbnMiOiB0aW1lLnRpbWVfbnMoKSwKICAgICAgICAgICAgICAgICJhZ2VudF9wcm9tcHQiOiBhZ2VudF9wcm9tcHQsCiAgICAgICAgICAgIH0KICAgICAgICApCiAgICApCgoKZGVmIF9jbGFpbV9wZW5kaW5nX2FnZW50X2NvbnRleHQocHJvbXB0OiBzdHIgPSAiIikgLT4gT3B0aW9uYWxbZGljdF06CiAgICAiIiJDbGFpbSB0aGUgcGVuZGluZyBzdWJhZ2VudCBjb250ZXh0IHRoYXQgbWF0Y2hlcyB0aGlzIHNlc3Npb24sIGlmIGFueS4KCiAgICBDYWxsZWQgZnJvbSBoYW5kbGVfdXNlcl9wcm9tcHRfc3VibWl0IHdoZW4gYSBuZXcgc2Vzc2lvbiBpcyBpbml0aWFsaXNlZC4KICAgIFJldHVybnMgdGhlIGNvbnRleHQgZGljdCAod2l0aCBgYHBhcmVudF90cmFjZV9pZGBgIGFuZCBgYHBhcmVudF9zcGFuX2lkYGApCiAgICBvciBOb25lLiAgQ2xhaW1pbmcgcmVtb3ZlcyB0aGUgZmlsZSBzbyBubyBvdGhlciBzZXNzaW9uIGNhbiB1c2UgaXQuCgogICAgV2hlbiBgYHByb21wdGBgIGlzIHByb3ZpZGVkICh0aGUgc3ViYWdlbnQncyBvd24gcHJvbXB0IHRleHQpIHdlIGZpcnN0IHRyeQogICAgdG8gZmluZCBhbiBleGFjdCBtYXRjaCBhZ2FpbnN0IGBgYWdlbnRfcHJvbXB0YGAgc3RvcmVkIGluIGVhY2ggY29udGV4dAogICAgZmlsZS4gIFRoaXMgcHJldmVudHMgcGFyYWxsZWwgYWdlbnQgaW52b2NhdGlvbnMgZnJvbSBjbGFpbWluZyBlYWNoIG90aGVyJ3MKICAgIGNvbnRleHRzLiAgSWYgbm8gZXhhY3QgbWF0Y2ggaXMgZm91bmQgd2UgZmFsbCBiYWNrIHRvIG5ld2VzdC1maXJzdCBzbyB0aGF0CiAgICBjb250ZXh0cyB3cml0dGVuIGJ5IG9sZGVyIHRyYWNlciB2ZXJzaW9ucyAod2hpY2ggbGFjayBgYGFnZW50X3Byb21wdGBgKSBhcmUKICAgIHN0aWxsIGNsYWltZWQuCiAgICAiIiIKICAgIGlmIG5vdCBfUEVORElOR19BR0VOVF9ESVIuZXhpc3RzKCk6CiAgICAgICAgcmV0dXJuIE5vbmUKCiAgICBkZWFkbGluZV9ucyA9IHRpbWUudGltZV9ucygpIC0gX1BFTkRJTkdfQUdFTlRfVElNRU9VVF9TICogMV8wMDBfMDAwXzAwMAogICAgY2FuZGlkYXRlczogbGlzdFt0dXBsZVtpbnQsIFBhdGgsIGRpY3RdXSA9IFtdCiAgICBmb3IgcCBpbiBfUEVORElOR19BR0VOVF9ESVIuZ2xvYigiKi5qc29uIik6CiAgICAgICAgdHJ5OgogICAgICAgICAgICBjdHggPSBqc29uLmxvYWRzKHAucmVhZF90ZXh0KCkpCiAgICAgICAgICAgIGlmIGN0eC5nZXQoImNyZWF0ZWRfbnMiLCAwKSA+PSBkZWFkbGluZV9uczoKICAgICAgICAgICAgICAgIGNhbmRpZGF0ZXMuYXBwZW5kKChjdHhbImNyZWF0ZWRfbnMiXSwgcCwgY3R4KSkKICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICBjb250aW51ZQoKICAgIGlmIG5vdCBjYW5kaWRhdGVzOgogICAgICAgIHJldHVybiBOb25lCgogICAgIyBTb3J0IG5ld2VzdC1maXJzdCBvbmNlOyB1c2VkIGJ5IGJvdGggdGhlIHByb21wdC1tYXRjaCBwYXNzIGFuZCBmYWxsYmFjay4KICAgIGNhbmRpZGF0ZXMuc29ydChrZXk9bGFtYmRhIHg6IHhbMF0sIHJldmVyc2U9VHJ1ZSkKCiAgICAjIFBhc3MgMTogZXhhY3QgcHJvbXB0IG1hdGNoIOKAlCBlbGltaW5hdGVzIHBhcmFsbGVsLWFnZW50IHJhY2VzLgogICAgaWYgcHJvbXB0OgogICAgICAgIGZvciBfLCBwYXRoLCBjdHggaW4gY2FuZGlkYXRlczoKICAgICAgICAgICAgaWYgY3R4LmdldCgiYWdlbnRfcHJvbXB0IiwgIiIpID09IHByb21wdDoKICAgICAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgICAgICBwYXRoLnVubGluaygpCiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGN0eAogICAgICAgICAgICAgICAgZXhjZXB0IEZpbGVOb3RGb3VuZEVycm9yOgogICAgICAgICAgICAgICAgICAgICMgQW5vdGhlciBwcm9jZXNzIGNsYWltZWQgaXQgZmlyc3Q7IGtlZXAgc2Nhbm5pbmcuCiAgICAgICAgICAgICAgICAgICAgY29udGludWUKCiAgICAjIFBhc3MgMjogZmFsbGJhY2sg4oCUIG5ld2VzdCB1bmNsYWltZWQgKGhhbmRsZXMgbGVnYWN5IGZpbGVzIHdpdGhvdXQgYWdlbnRfcHJvbXB0KS4KICAgIGZvciBfLCBwYXRoLCBjdHggaW4gY2FuZGlkYXRlczoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHBhdGgudW5saW5rKCkKICAgICAgICAgICAgcmV0dXJuIGN0eAogICAgICAgIGV4Y2VwdCBGaWxlTm90Rm91bmRFcnJvcjoKICAgICAgICAgICAgY29udGludWUKICAgIHJldHVybiBOb25lCgoKQGNvbnRleHRsaWIuY29udGV4dG1hbmFnZXIKZGVmIF9zZXNzaW9uX2xvY2soc2Vzc2lvbl9pZDogc3RyKToKICAgICIiIkV4Y2x1c2l2ZSBwZXItc2Vzc2lvbiBmaWxlIGxvY2suCgogICAgU2VyaWFsaXNlcyBjb25jdXJyZW50IGhhbmRsZV9wb3N0X3Rvb2wgLyBoYW5kbGVfcG9zdF90b29sX2ZhaWx1cmUKICAgIHByb2Nlc3NlcyBzbyB0aGF0IF9lbWl0X3BlbmRpbmdfbGxtX3NwYW5zIHJlYWRzIGEgY29uc2lzdGVudAogICAgZW1pdHRlZF9sbG1fc3Bhbl9jb3VudCBhbmQgbmV2ZXIgZW1pdHMgdGhlIHNhbWUgTExNIHNwYW4gdHdpY2UuCiAgICBUaGlzIGlzIG5lY2Vzc2FyeSBiZWNhdXNlIENsYXVkZSBDb2RlIGNhbiBydW4gbXVsdGlwbGUgdG9vbHMgaW4gcGFyYWxsZWwsCiAgICB3aGljaCBjYXVzZXMgbXVsdGlwbGUgUG9zdFRvb2xVc2UgaG9vayBwcm9jZXNzZXMgdG8gZmlyZSBjb25jdXJyZW50bHkuCiAgICAiIiIKICAgIFNUQVRFX0RJUi5ta2RpcihwYXJlbnRzPVRydWUsIGV4aXN0X29rPVRydWUpCiAgICBsb2NrX3BhdGggPSAoU1RBVEVfRElSIC8gZiJ7c2Vzc2lvbl9pZH0ubG9jayIpLnJlc29sdmUoKQogICAgaWYgbm90IGxvY2tfcGF0aC5pc19yZWxhdGl2ZV90byhTVEFURV9ESVIucmVzb2x2ZSgpKToKICAgICAgICByYWlzZSBWYWx1ZUVycm9yKGYiSW52YWxpZCBzZXNzaW9uX2lkOiB7c2Vzc2lvbl9pZCFyfSIpCgogICAgd2l0aCBvcGVuKGxvY2tfcGF0aCwgInciKSBhcyBmaDoKICAgICAgICBmY250bC5mbG9jayhmaCwgZmNudGwuTE9DS19FWCkKICAgICAgICB0cnk6CiAgICAgICAgICAgIHlpZWxkCiAgICAgICAgZmluYWxseToKICAgICAgICAgICAgZmNudGwuZmxvY2soZmgsIGZjbnRsLkxPQ0tfVU4pCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBUcmFuc2NyaXB0IGhlbHBlcnMKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KCgpkZWYgX2lzX3JlYWxfdHJhbnNjcmlwdChwYXRoOiBzdHIpIC0+IGJvb2w6CiAgICAiIiJSZXR1cm4gVHJ1ZSBpZiB0aGUgdHJhbnNjcmlwdCBoYXMgYXQgbGVhc3Qgb25lIGh1bWFuIG9yIGFzc2lzdGFudCBlbnRyeS4KCiAgICBTaGFkb3cgdHJhbnNjcmlwdHMgY3JlYXRlZCBieSBgYGdoIHByIGNyZWF0ZWBgIGluIGEgd29ya3RyZWUgY29udGFpbiBvbmx5CiAgICBgYHByLWxpbmtgYCBtZXRhZGF0YSBlbnRyaWVzIGFuZCBubyBhY3R1YWwgY29udmVyc2F0aW9uIGNvbnRlbnQuICBBY2NlcHRpbmcKICAgIG9uZSBvZiB0aGVzZSBhcyB0aGUgYXV0aG9yaXRhdGl2ZSB0cmFuc2NyaXB0IHdvdWxkIGxlYXZlIHRoZSB0cmFjZXIgd2l0aAogICAgbm90aGluZyB0byBleHRyYWN0IExMTSBzcGFucyBmcm9tLgogICAgIiIiCiAgICB0cnk6CiAgICAgICAgd2l0aCBvcGVuKHBhdGgpIGFzIGZoOgogICAgICAgICAgICBmb3IgbGluZSBpbiBmaDoKICAgICAgICAgICAgICAgIGxpbmUgPSBsaW5lLnN0cmlwKCkKICAgICAgICAgICAgICAgIGlmIG5vdCBsaW5lOgogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICAgICAgZW50cnkgPSBqc29uLmxvYWRzKGxpbmUpCiAgICAgICAgICAgICAgICBleGNlcHQganNvbi5KU09ORGVjb2RlRXJyb3I6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIGlmIGVudHJ5LmdldCgidHlwZSIpIGluICgidXNlciIsICJhc3Npc3RhbnQiKToKICAgICAgICAgICAgICAgICAgICByZXR1cm4gVHJ1ZQogICAgICAgIHJldHVybiBGYWxzZQogICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgcmV0dXJuIEZhbHNlCgoKZGVmIF9maW5kX3RyYW5zY3JpcHRfcGF0aChkYXRhOiBkaWN0LCBzZXNzaW9uX2lkOiBzdHIpIC0+IE9wdGlvbmFsW3N0cl06CiAgICAiIiJMb2NhdGUgdGhlIHNlc3Npb24ncyB0cmFuc2NyaXB0IEpTT05MIGZpbGUuCgogICAgU2VhcmNoZXMgdGhyZWUgbG9jYXRpb25zIGluIHByaW9yaXR5IG9yZGVyIGFuZCByZXR1cm5zIHRoZSBmaXJzdCBtYXRjaAogICAgdGhhdCBjb250YWlucyByZWFsIGNvbnZlcnNhdGlvbiBjb250ZW50IChhdCBsZWFzdCBvbmUgdXNlci9hc3Npc3RhbnQgZW50cnkpLgogICAgQ2FsbGVycyB0aGF0IG5lZWQgYSBzdGFibGUgcGF0aCBhY3Jvc3MgdGhlIGxpZmV0aW1lIG9mIGEgc2Vzc2lvbiBzaG91bGQKICAgIGNhY2hlIHRoZSByZXN1bHQgaW4gc2Vzc2lvbiBzdGF0ZSB2aWEgYGBfZ2V0X2NhY2hlZF90cmFuc2NyaXB0X3BhdGhgYAogICAgcmF0aGVyIHRoYW4gY2FsbGluZyB0aGlzIGZ1bmN0aW9uIHJlcGVhdGVkbHkg4oCUIHRoZSBgYHRyYW5zY3JpcHRfcGF0aGBgCiAgICB2YWx1ZSBpbiBob29rIGBgZGF0YWBgIGNhbiBjaGFuZ2UgbWlkLXNlc3Npb24gd2hlbiBDbGF1ZGUgQ29kZSB3cml0ZXMKICAgIGVudHJpZXMgdG8gYSAqZGlmZmVyZW50KiBwcm9qZWN0IGRpcmVjdG9yeSAoZS5nLiBhZnRlciBgYGdoIHByIGNyZWF0ZWBgCiAgICBpbiBhIHdvcmt0cmVlIGNvbnRleHQpLgogICAgIiIiCiAgICBjYW5kaWRhdGVzOiBsaXN0W3N0cl0gPSBbXQoKICAgICMgMS4gUHJvdmlkZWQgZGlyZWN0bHkgaW4gaG9vayBkYXRhCiAgICB0cCA9IGRhdGEuZ2V0KCJ0cmFuc2NyaXB0X3BhdGgiLCAiIikKICAgIGlmIHRwIGFuZCBQYXRoKHRwKS5leGlzdHMoKToKICAgICAgICBjYW5kaWRhdGVzLmFwcGVuZCh0cCkKCiAgICAjIDIuIENvbnN0cnVjdCBmcm9tIENMQVVERV9QUk9KRUNUX0RJUiAocGF0aCB3aXRoIC8g4oaSIC0pCiAgICBwcm9qZWN0X2RpciA9IG9zLmVudmlyb24uZ2V0KCJDTEFVREVfUFJPSkVDVF9ESVIiLCAiIikKICAgIGlmIHByb2plY3RfZGlyOgogICAgICAgIHNhbml0aXplZCA9IHByb2plY3RfZGlyLnJlcGxhY2UoIi8iLCAiLSIpCiAgICAgICAgcGF0aCA9IFBhdGguaG9tZSgpIC8gIi5jbGF1ZGUiIC8gInByb2plY3RzIiAvIHNhbml0aXplZCAvIGYie3Nlc3Npb25faWR9Lmpzb25sIgogICAgICAgIGlmIHBhdGguZXhpc3RzKCk6CiAgICAgICAgICAgIGNhbmRpZGF0ZXMuYXBwZW5kKHN0cihwYXRoKSkKCiAgICAjIDMuIEdsb2IgZmFsbGJhY2sgYWNyb3NzIGFsbCBwcm9qZWN0IGRpcnMgKHNvcnRlZCBsYXJnZXN0LWZpcnN0IHNvIHRoZQogICAgIyAgICByZWFsIHRyYW5zY3JpcHQgd2lucyBvdmVyIHRpbnkgc2hhZG93IGZpbGVzKQogICAgcHJvamVjdHNfZGlyID0gUGF0aC5ob21lKCkgLyAiLmNsYXVkZSIgLyAicHJvamVjdHMiCiAgICBpZiBwcm9qZWN0c19kaXIuZXhpc3RzKCk6CiAgICAgICAgbWF0Y2hlcyA9IGxpc3QocHJvamVjdHNfZGlyLmdsb2IoZiIqL3tzZXNzaW9uX2lkfS5qc29ubCIpKQogICAgICAgIG1hdGNoZXMuc29ydChrZXk9bGFtYmRhIHA6IHAuc3RhdCgpLnN0X3NpemUsIHJldmVyc2U9VHJ1ZSkKICAgICAgICBjYW5kaWRhdGVzLmV4dGVuZChzdHIobSkgZm9yIG0gaW4gbWF0Y2hlcykKCiAgICAjIFJldHVybiB0aGUgZmlyc3QgY2FuZGlkYXRlIHRoYXQgY29udGFpbnMgYWN0dWFsIGNvbnZlcnNhdGlvbiBlbnRyaWVzCiAgICBzZWVuOiBzZXRbc3RyXSA9IHNldCgpCiAgICBmb3IgY2FuZGlkYXRlIGluIGNhbmRpZGF0ZXM6CiAgICAgICAgaWYgY2FuZGlkYXRlIGluIHNlZW46CiAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgc2Vlbi5hZGQoY2FuZGlkYXRlKQogICAgICAgIGlmIF9pc19yZWFsX3RyYW5zY3JpcHQoY2FuZGlkYXRlKToKICAgICAgICAgICAgcmV0dXJuIGNhbmRpZGF0ZQoKICAgIHJldHVybiBOb25lCgoKZGVmIF9nZXRfY2FjaGVkX3RyYW5zY3JpcHRfcGF0aCgKICAgIGRhdGE6IGRpY3QsCiAgICBzdGF0ZTogZGljdCwKICAgIHNlc3Npb25faWQ6IHN0ciwKKSAtPiBPcHRpb25hbFtzdHJdOgogICAgIiIiUmV0dXJuIHRoZSB0cmFuc2NyaXB0IHBhdGggZm9yIHRoaXMgc2Vzc2lvbiwgY2FjaGluZyBpdCBpbiAqc3RhdGUqLgoKICAgIE9uY2UgcmVzb2x2ZWQsIHRoZSBwYXRoIGlzIHN0b3JlZCB1bmRlciBgYHN0YXRlWyJ0cmFuc2NyaXB0X3BhdGgiXWBgIHNvCiAgICB0aGF0IGFsbCBzdWJzZXF1ZW50IGhvb2sgY2FsbHMgdXNlIHRoZSBzYW1lIGZpbGUgZXZlbiBpZiBDbGF1ZGUgQ29kZSBsYXRlcgogICAgd3JpdGVzIGEgbmV3IGVudHJ5IHRvIGEgZGlmZmVyZW50IHByb2plY3QgZGlyZWN0b3J5IChlLmcuIGEgd29ya3RyZWUgZGlyCiAgICBhZnRlciBgYGdoIHByIGNyZWF0ZWBgKS4gIFRoZSBjYWNoZSBpcyBvbmx5IGFjY2VwdGVkIHdoZW4gdGhlIGZpbGUgc3RpbGwKICAgIGV4aXN0czsgaWYgaXQgaGFzIGJlZW4gZGVsZXRlZCB0aGUgZnVuY3Rpb24gcmUtcmVzb2x2ZXMuCiAgICAiIiIKICAgIGNhY2hlZCA9IHN0YXRlLmdldCgidHJhbnNjcmlwdF9wYXRoIiwgIiIpCiAgICBpZiBjYWNoZWQgYW5kIFBhdGgoY2FjaGVkKS5leGlzdHMoKToKICAgICAgICByZXR1cm4gY2FjaGVkCgogICAgcmVzb2x2ZWQgPSBfZmluZF90cmFuc2NyaXB0X3BhdGgoZGF0YSwgc2Vzc2lvbl9pZCkKICAgIGlmIHJlc29sdmVkOgogICAgICAgIHN0YXRlWyJ0cmFuc2NyaXB0X3BhdGgiXSA9IHJlc29sdmVkCiAgICByZXR1cm4gcmVzb2x2ZWQKCgpkZWYgX2lzX2h1bWFuX21lc3NhZ2UoZW50cnk6IGRpY3QpIC0+IGJvb2w6CiAgICAiIiJUcnVlIGZvciB1c2VyLWluaXRpYXRlZCBtZXNzYWdlcyAobm90IHRvb2wgcmVzdWx0cykuIiIiCiAgICBpZiBlbnRyeS5nZXQoInR5cGUiKSAhPSAidXNlciI6CiAgICAgICAgcmV0dXJuIEZhbHNlCiAgICBjb250ZW50ID0gZW50cnkuZ2V0KCJtZXNzYWdlIiwge30pLmdldCgiY29udGVudCIsICIiKQogICAgIyBUb29sIHJlc3VsdCBtZXNzYWdlcyBoYXZlIGNvbnRlbnQgYXMgYSBsaXN0OyBodW1hbiBtZXNzYWdlcyBoYXZlIGEgc3RyaW5nCiAgICByZXR1cm4gaXNpbnN0YW5jZShjb250ZW50LCBzdHIpCgoKZGVmIF9jb3VudF9odW1hbl9tZXNzYWdlcyh0cmFuc2NyaXB0X3BhdGg6IHN0cikgLT4gaW50OgogICAgIiIiQ291bnQgaHVtYW4gKG5vbi10b29sLXJlc3VsdCkgdXNlciBtZXNzYWdlcyB0byBkZXRlY3QgdHVybiBib3VuZGFyaWVzLiIiIgogICAgdHJ5OgogICAgICAgIGNvdW50ID0gMAogICAgICAgIGZvciBsaW5lIGluIFBhdGgodHJhbnNjcmlwdF9wYXRoKS5yZWFkX3RleHQoKS5zcGxpdGxpbmVzKCk6CiAgICAgICAgICAgIGlmIG5vdCBsaW5lLnN0cmlwKCk6CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBpZiBfaXNfaHVtYW5fbWVzc2FnZShqc29uLmxvYWRzKGxpbmUpKToKICAgICAgICAgICAgICAgICAgICBjb3VudCArPSAxCiAgICAgICAgICAgIGV4Y2VwdCBqc29uLkpTT05EZWNvZGVFcnJvcjoKICAgICAgICAgICAgICAgIHBhc3MKICAgICAgICByZXR1cm4gY291bnQKICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgcmV0dXJuIDAKCgpkZWYgX2lzb190b19ucyh0czogc3RyKSAtPiBpbnQ6CiAgICAiIiJDb252ZXJ0IElTTyA4NjAxIHRpbWVzdGFtcCBzdHJpbmcgdG8gbmFub3NlY29uZHMuIiIiCiAgICB0cnk6CiAgICAgICAgZHQgPSBkYXRldGltZS5mcm9taXNvZm9ybWF0KHRzLnJlcGxhY2UoIloiLCAiKzAwOjAwIikpCiAgICAgICAgcmV0dXJuIGludChkdC50aW1lc3RhbXAoKSAqIDFfMDAwXzAwMF8wMDApCiAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgIHJldHVybiB0aW1lLnRpbWVfbnMoKQoKCmRlZiBfdG9vbF9yZXN1bHRfdGV4dChjb250ZW50OiBsaXN0KSAtPiBzdHI6CiAgICAiIiJFeHRyYWN0IHJlYWRhYmxlIHRleHQgZnJvbSBhIHRvb2xfcmVzdWx0IGNvbnRlbnQgbGlzdC4iIiIKICAgIHBhcnRzID0gW10KICAgIGZvciBpdGVtIGluIGNvbnRlbnQ6CiAgICAgICAgaWYgbm90IGlzaW5zdGFuY2UoaXRlbSwgZGljdCk6CiAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgaWYgaXRlbS5nZXQoInR5cGUiKSA9PSAidG9vbF9yZXN1bHQiOgogICAgICAgICAgICBpbm5lciA9IGl0ZW0uZ2V0KCJjb250ZW50IiwgIiIpCiAgICAgICAgICAgIGlmIGlzaW5zdGFuY2UoaW5uZXIsIHN0cik6CiAgICAgICAgICAgICAgICBwYXJ0cy5hcHBlbmQoaW5uZXIpCiAgICAgICAgICAgIGVsaWYgaXNpbnN0YW5jZShpbm5lciwgbGlzdCk6CiAgICAgICAgICAgICAgICBmb3IgcmMgaW4gaW5uZXI6CiAgICAgICAgICAgICAgICAgICAgaWYgaXNpbnN0YW5jZShyYywgZGljdCkgYW5kIHJjLmdldCgidHlwZSIpID09ICJ0ZXh0IjoKICAgICAgICAgICAgICAgICAgICAgICAgcGFydHMuYXBwZW5kKHJjLmdldCgidGV4dCIsICIiKSkKICAgIHJldHVybiAiXG4iLmpvaW4ocGFydHMpCgoKZGVmIF9leHRyYWN0X2xsbV9zcGFuc19mb3JfdHVybigKICAgIHRyYW5zY3JpcHRfcGF0aDogc3RyLAogICAgaHVtYW5fY291bnRfYXRfc3RhcnQ6IGludCwKICAgIHRyYWNlX2lkX2hleDogc3RyLAogICAgcm9vdF9zcGFuX2lkX2hleDogc3RyLAopIC0+IGxpc3RbZGljdF06CiAgICAiIiIKICAgIEV4dHJhY3QgTExNIHNwYW5zIGZyb20gdHJhbnNjcmlwdCBlbnRyaWVzIHRoYXQgYmVsb25nIHRvIHRoaXMgdHVybi4KCiAgICBBIHR1cm4gc3RhcnRzIGFmdGVyIHRoZSBgaHVtYW5fY291bnRfYXRfc3RhcnRgLXRoIGh1bWFuIG1lc3NhZ2UgYW5kIGVuZHMKICAgIGF0IHRoZSBuZXh0IGh1bWFuIG1lc3NhZ2UgKG9yIGVuZCBvZiB0cmFuc2NyaXB0KS4gIFNjYW5uaW5nIHN0b3BzIGFzIHNvb24KICAgIGFzIGEgc2Vjb25kIGh1bWFuIG1lc3NhZ2UgaXMgc2VlbiB3aGlsZSBpbl90dXJuIGlzIFRydWUg4oCUIHRob3NlIHN1YnNlcXVlbnQKICAgIHR1cm5zIGJlbG9uZyB0byB0aGVpciBvd24gdHJhY2VzLgogICAgVXNlcyBhY3R1YWwgdGltZXN0YW1wcyBmcm9tIHRoZSB0cmFuc2NyaXB0LgoKICAgIEZvciBlYWNoIExMTSBjYWxsIHdlIHVzZSB0aGUgaW1tZWRpYXRlbHktcHJlY2VkaW5nIG1lc3NhZ2UgYXMgdGhlIGlucHV0OgogICAgICAtIEZpcnN0IGNhbGwgaW4gdHVybiAg4oaSIHRoZSBodW1hbiBwcm9tcHQgKHRleHQvcGxhaW4pCiAgICAgIC0gU3Vic2VxdWVudCBjYWxscyAgICDihpIgdGhlIHRvb2wgcmVzdWx0KHMpIHRoYXQgcHJlY2VkZWQgdGhlbSAoYXBwbGljYXRpb24vanNvbikKCiAgICBPdXRwdXQgaXMgdGhlIGFzc2lzdGFudCdzIHRleHQgcmVzcG9uc2Ugd2hlbiBwcmVzZW50OyBvdGhlcndpc2UgdGhlCiAgICB0b29sX3VzZSBibG9ja3Mgc2VyaWFsaXNlZCBhcyBKU09OIHNvIHRoZSBzcGFuIGFsd2F5cyBoYXMgYW4gb3V0cHV0IHZhbHVlLgoKICAgICoqR3JvdXBpbmcgYnkgbWVzc2FnZS5pZCoqCgogICAgQ2xhdWRlIENvZGUgKHdpdGggZXh0ZW5kZWQgdGhpbmtpbmcgZW5hYmxlZCkgc3BsaXRzIGEgc2luZ2xlIEFQSSByZXNwb25zZQogICAgYWNyb3NzIG11bHRpcGxlIGNvbnNlY3V0aXZlIHRyYW5zY3JpcHQgZW50cmllcyB0aGF0IHNoYXJlIHRoZSBzYW1lCiAgICBgYG1lc3NhZ2UuaWRgYCDigJQgb25lIGVudHJ5IHBlciBibG9jayB0eXBlICh0aGlua2luZyAvIHRleHQgLyB0b29sX3VzZSkuCiAgICBFYWNoIGVudHJ5IGR1cGxpY2F0ZXMgdGhlIGlucHV0LXNpZGUgdG9rZW4gY291bnRzLiAgV2UgTVVTVCBncm91cCB0aGVzZQogICAgZW50cmllcyBhbmQgZW1pdCBleGFjdGx5IG9uZSBMTE0gc3BhbiBwZXIgQVBJIHJlc3BvbnNlLCBvdGhlcndpc2U6CgogICAgICAqIFByb21wdCB0b2tlbnMgYXJlIHJlcG9ydGVkIE7DlyB0b28gaGlnaCAob25jZSBwZXIgZW50cnkpLgogICAgICAqICJUaGlua2luZy1vbmx5IiBlbnRyaWVzIHByb2R1Y2UgZW1wdHksIG5vaXN5IHNwYW5zLgogICAgICAqIEludGVybWVkaWF0ZSB0ZXh0ICh0aGUgZW50cnkgd2l0aCBgYHR5cGU9dGV4dGBgIHRoYXQgcHJlY2VkZXMgYSB0b29sCiAgICAgICAgY2FsbCkgYmVjb21lcyBpdHMgb3duIG9ycGhhbmVkIDgtdG9rZW4gc3BhbiBpbnN0ZWFkIG9mIGJlaW5nIHBhcnQgb2YKICAgICAgICB0aGUgc2luZ2xlIHNwYW4gZm9yIHRoYXQgQVBJIGNhbGwuCgogICAgR3JvdXBpbmcgc3RyYXRlZ3k6CiAgICAgIC0gSW5wdXQgdG9rZW5zICDihpIgZmlyc3QgZW50cnkgaW4gdGhlIGdyb3VwIChhdm9pZHMgZHVwbGljYXRpb24pLgogICAgICAtIE91dHB1dCB0b2tlbnMg4oaSIHN1bSBhY3Jvc3MgYWxsIGVudHJpZXMgKGVhY2ggZW50cnkgY2FwdHVyZXMgdGhlIHRva2VucwogICAgICAgICAgICAgICAgICAgICAgICBmb3IgaXRzIG93biBibG9jaykuCiAgICAgIC0gVGV4dCBvdXRwdXQgICDihpIgY29uY2F0ZW5hdGUgYWxsIGBgdGV4dGBgIGJsb2NrcyBmcm9tIGFueSBlbnRyeS4KICAgICAgLSBUb29sIG91dHB1dCAgIOKGkiBKU09OIG9mIHRvb2xfdXNlIGJsb2NrcyBmcm9tIHRoZSBsYXN0IGVudHJ5IChmYWxsLWJhY2sKICAgICAgICAgICAgICAgICAgICAgICAgd2hlbiBubyB0ZXh0IGlzIHByZXNlbnQpLgogICAgICAtIFRpbWVzdGFtcCAgICAg4oaSIGZpcnN0IGVudHJ5LgogICAgIiIiCiAgICBzcGFucyA9IFtdCiAgICB0cnk6CiAgICAgICAgbGluZXMgPSBQYXRoKHRyYW5zY3JpcHRfcGF0aCkucmVhZF90ZXh0KCkuc3BsaXRsaW5lcygpCiAgICAgICAgaHVtYW5fY291bnQgPSAwCiAgICAgICAgaW5fdHVybiA9IEZhbHNlCgogICAgICAgICMgVHJhY2tzIHRoZSBpbnB1dCBmb3IgdGhlICpuZXh0KiBMTE0gY2FsbCB3ZSBlbmNvdW50ZXIKICAgICAgICBsYXN0X2lucHV0X3ZhbHVlID0gIiIKICAgICAgICBsYXN0X2lucHV0X21pbWUgPSAidGV4dC9wbGFpbiIKICAgICAgICBsYXN0X2lucHV0X3JvbGUgPSAidXNlciIKICAgICAgICBsYXN0X2lucHV0X2NvbnRlbnQgPSAiIgoKICAgICAgICAjIEFjY3VtdWxhdG9yIGZvciB0aGUgY3VycmVudCBtZXNzYWdlLmlkIGdyb3VwCiAgICAgICAgY3VycmVudF9ncm91cF9pZDogT3B0aW9uYWxbc3RyXSA9IE5vbmUKICAgICAgICBncm91cF9maXJzdF91c2FnZTogZGljdCA9IHt9CiAgICAgICAgZ3JvdXBfdG90YWxfb3V0cHV0X3Rva2VuczogaW50ID0gMAogICAgICAgIGdyb3VwX3RleHRfcGFydHM6IGxpc3QgPSBbXQogICAgICAgIGdyb3VwX3Rvb2xfdXNlX3BhcnRzOiBsaXN0ID0gW10KICAgICAgICBncm91cF9tb2RlbDogc3RyID0gImNsYXVkZSIKICAgICAgICBncm91cF90czogc3RyID0gIiIKICAgICAgICBncm91cF9pbnB1dF9zbmFwc2hvdDogZGljdCA9IHt9ICAjIGxhc3RfaW5wdXQqIGNhcHR1cmVkIGF0IGdyb3VwIHN0YXJ0CgogICAgICAgIGRlZiBfZmx1c2hfZ3JvdXAoKSAtPiBOb25lOgogICAgICAgICAgICAiIiJFbWl0IG9uZSBMTE0gc3BhbiBmb3IgdGhlIGFjY3VtdWxhdGVkIGdyb3VwLCBpZiBub24tZW1wdHkuIiIiCiAgICAgICAgICAgIG5vbmxvY2FsIGN1cnJlbnRfZ3JvdXBfaWQsIGdyb3VwX2ZpcnN0X3VzYWdlLCBncm91cF90b3RhbF9vdXRwdXRfdG9rZW5zCiAgICAgICAgICAgIG5vbmxvY2FsIGdyb3VwX3RleHRfcGFydHMsIGdyb3VwX3Rvb2xfdXNlX3BhcnRzLCBncm91cF9tb2RlbCwgZ3JvdXBfdHMKICAgICAgICAgICAgbm9ubG9jYWwgZ3JvdXBfaW5wdXRfc25hcHNob3QKCiAgICAgICAgICAgIGlmIG5vdCBjdXJyZW50X2dyb3VwX2lkOgogICAgICAgICAgICAgICAgcmV0dXJuCgogICAgICAgICAgICB1c2FnZSA9IGdyb3VwX2ZpcnN0X3VzYWdlCiAgICAgICAgICAgIGlucHV0X3Rva2VucyA9IHVzYWdlLmdldCgiaW5wdXRfdG9rZW5zIiwgMCkKICAgICAgICAgICAgY2FjaGVfcmVhZCA9IHVzYWdlLmdldCgiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiLCAwKQogICAgICAgICAgICBjYWNoZV9jcmVhdGUgPSB1c2FnZS5nZXQoImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyIsIDApCiAgICAgICAgICAgIG91dHB1dF90b2tlbnMgPSBncm91cF90b3RhbF9vdXRwdXRfdG9rZW5zCgogICAgICAgICAgICBpZiBncm91cF90ZXh0X3BhcnRzOgogICAgICAgICAgICAgICAgb3V0cHV0X3ZhbHVlID0gX3RydW5jYXRlKCIiLmpvaW4oZ3JvdXBfdGV4dF9wYXJ0cykpCiAgICAgICAgICAgICAgICBvdXRwdXRfbWltZSA9ICJ0ZXh0L3BsYWluIgogICAgICAgICAgICBlbGlmIGdyb3VwX3Rvb2xfdXNlX3BhcnRzOgogICAgICAgICAgICAgICAgb3V0cHV0X3ZhbHVlID0gX3RydW5jYXRlKGpzb24uZHVtcHMoZ3JvdXBfdG9vbF91c2VfcGFydHMpKQogICAgICAgICAgICAgICAgb3V0cHV0X21pbWUgPSAiYXBwbGljYXRpb24vanNvbiIKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIG91dHB1dF92YWx1ZSA9ICIiCiAgICAgICAgICAgICAgICBvdXRwdXRfbWltZSA9ICJ0ZXh0L3BsYWluIgoKICAgICAgICAgICAgc3RhcnRfbnMgPSBfaXNvX3RvX25zKGdyb3VwX3RzKQogICAgICAgICAgICBlbmRfbnMgPSBzdGFydF9ucyArIG1heChvdXRwdXRfdG9rZW5zICogMTBfMDAwXzAwMCwgMV8wMDBfMDAwKQoKICAgICAgICAgICAgc25hcCA9IGdyb3VwX2lucHV0X3NuYXBzaG90CiAgICAgICAgICAgIGF0dHJzOiBkaWN0W3N0ciwgQW55XSA9IHsKICAgICAgICAgICAgICAgICJvcGVuaW5mZXJlbmNlLnNwYW4ua2luZCI6ICJMTE0iLAogICAgICAgICAgICAgICAgImxsbS5zeXN0ZW0iOiAiYW50aHJvcGljIiwKICAgICAgICAgICAgICAgICJsbG0ubW9kZWxfbmFtZSI6IGdyb3VwX21vZGVsLAogICAgICAgICAgICAgICAgImxsbS50b2tlbl9jb3VudC5wcm9tcHQiOiBpbnB1dF90b2tlbnMgKyBjYWNoZV9yZWFkICsgY2FjaGVfY3JlYXRlLAogICAgICAgICAgICAgICAgImxsbS50b2tlbl9jb3VudC5jb21wbGV0aW9uIjogb3V0cHV0X3Rva2VucywKICAgICAgICAgICAgICAgICJsbG0udG9rZW5fY291bnQudG90YWwiOiBpbnB1dF90b2tlbnMKICAgICAgICAgICAgICAgICsgY2FjaGVfcmVhZAogICAgICAgICAgICAgICAgKyBjYWNoZV9jcmVhdGUKICAgICAgICAgICAgICAgICsgb3V0cHV0X3Rva2VucywKICAgICAgICAgICAgICAgICJsbG0udG9rZW5fY291bnQucHJvbXB0X2RldGFpbHMuY2FjaGVfcmVhZCI6IGNhY2hlX3JlYWQsCiAgICAgICAgICAgICAgICAibGxtLnRva2VuX2NvdW50LnByb21wdF9kZXRhaWxzLmNhY2hlX3dyaXRlIjogY2FjaGVfY3JlYXRlLAogICAgICAgICAgICAgICAgImxsbS5pbnB1dF9tZXNzYWdlcy4wLm1lc3NhZ2Uucm9sZSI6IHNuYXAuZ2V0KCJyb2xlIiwgInVzZXIiKSwKICAgICAgICAgICAgICAgICJsbG0uaW5wdXRfbWVzc2FnZXMuMC5tZXNzYWdlLmNvbnRlbnQiOiBzbmFwLmdldCgiY29udGVudCIsICIiKSwKICAgICAgICAgICAgICAgICJpbnB1dC52YWx1ZSI6IHNuYXAuZ2V0KCJ2YWx1ZSIsICIiKSwKICAgICAgICAgICAgICAgICJpbnB1dC5taW1lX3R5cGUiOiBzbmFwLmdldCgibWltZSIsICJ0ZXh0L3BsYWluIiksCiAgICAgICAgICAgICAgICAibGxtLm91dHB1dF9tZXNzYWdlcy4wLm1lc3NhZ2Uucm9sZSI6ICJhc3Npc3RhbnQiLAogICAgICAgICAgICAgICAgImxsbS5vdXRwdXRfbWVzc2FnZXMuMC5tZXNzYWdlLmNvbnRlbnQiOiBvdXRwdXRfdmFsdWUsCiAgICAgICAgICAgICAgICAib3V0cHV0LnZhbHVlIjogb3V0cHV0X3ZhbHVlLAogICAgICAgICAgICAgICAgIm91dHB1dC5taW1lX3R5cGUiOiBvdXRwdXRfbWltZSwKICAgICAgICAgICAgfQoKICAgICAgICAgICAgc3BhbnMuYXBwZW5kKAogICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICJ0cmFjZV9pZF9oZXgiOiB0cmFjZV9pZF9oZXgsCiAgICAgICAgICAgICAgICAgICAgInNwYW5faWRfaGV4IjogX25ld19zcGFuX2lkKCksCiAgICAgICAgICAgICAgICAgICAgInBhcmVudF9zcGFuX2lkX2hleCI6IHJvb3Rfc3Bhbl9pZF9oZXgsCiAgICAgICAgICAgICAgICAgICAgIm5hbWUiOiBmImNsYXVkZS97Z3JvdXBfbW9kZWx9IiwKICAgICAgICAgICAgICAgICAgICAia2luZCI6IE5vbmUsCiAgICAgICAgICAgICAgICAgICAgInN0YXJ0X25zIjogc3RhcnRfbnMsCiAgICAgICAgICAgICAgICAgICAgImVuZF9ucyI6IGVuZF9ucywKICAgICAgICAgICAgICAgICAgICAiYXR0cmlidXRlcyI6IGF0dHJzLAogICAgICAgICAgICAgICAgICAgICJmb3JjZV9zcGFuX2lkIjogRmFsc2UsCiAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICApCgogICAgICAgICAgICAjIFJlc2V0IGFjY3VtdWxhdG9yCiAgICAgICAgICAgIGN1cnJlbnRfZ3JvdXBfaWQgPSBOb25lCiAgICAgICAgICAgIGdyb3VwX2ZpcnN0X3VzYWdlID0ge30KICAgICAgICAgICAgZ3JvdXBfdG90YWxfb3V0cHV0X3Rva2VucyA9IDAKICAgICAgICAgICAgZ3JvdXBfdGV4dF9wYXJ0cyA9IFtdCiAgICAgICAgICAgIGdyb3VwX3Rvb2xfdXNlX3BhcnRzID0gW10KICAgICAgICAgICAgZ3JvdXBfbW9kZWwgPSAiY2xhdWRlIgogICAgICAgICAgICBncm91cF90cyA9ICIiCiAgICAgICAgICAgIGdyb3VwX2lucHV0X3NuYXBzaG90ID0ge30KCiAgICAgICAgZm9yIGxpbmUgaW4gbGluZXM6CiAgICAgICAgICAgIGlmIG5vdCBsaW5lLnN0cmlwKCk6CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBlbnRyeSA9IGpzb24ubG9hZHMobGluZSkKICAgICAgICAgICAgZXhjZXB0IGpzb24uSlNPTkRlY29kZUVycm9yOgogICAgICAgICAgICAgICAgY29udGludWUKCiAgICAgICAgICAgIGVudHJ5X3R5cGUgPSBlbnRyeS5nZXQoInR5cGUiLCAiIikKCiAgICAgICAgICAgICMg4pSA4pSAIEh1bWFuIG1lc3NhZ2U6IG1hcmtzIHRoZSBzdGFydCBvZiB0aGlzIHR1cm4g4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICAgICAgICAgIGlmIF9pc19odW1hbl9tZXNzYWdlKGVudHJ5KToKICAgICAgICAgICAgICAgIF9mbHVzaF9ncm91cCgpCiAgICAgICAgICAgICAgICBodW1hbl9jb3VudCArPSAxCiAgICAgICAgICAgICAgICBpZiBpbl90dXJuOgogICAgICAgICAgICAgICAgICAgICMgTmV4dCB1c2VyIHR1cm4gaGFzIHN0YXJ0ZWQg4oCUIHN0b3AgaGVyZS4gIEl0cyBzcGFucyBiZWxvbmcKICAgICAgICAgICAgICAgICAgICAjIHRvIGEgZGlmZmVyZW50IHRyYWNlLgogICAgICAgICAgICAgICAgICAgIGJyZWFrCiAgICAgICAgICAgICAgICBpZiBodW1hbl9jb3VudCA+IGh1bWFuX2NvdW50X2F0X3N0YXJ0OgogICAgICAgICAgICAgICAgICAgIGluX3R1cm4gPSBUcnVlCiAgICAgICAgICAgICAgICAgICAgaHVtYW5fdGV4dCA9IGVudHJ5LmdldCgibWVzc2FnZSIsIHt9KS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X3ZhbHVlID0ganNvbi5kdW1wcygKICAgICAgICAgICAgICAgICAgICAgICAgeyJyb2xlIjogInVzZXIiLCAiY29udGVudCI6IGh1bWFuX3RleHRbOjUwMF19LAogICAgICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X21pbWUgPSAiYXBwbGljYXRpb24vanNvbiIKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X3JvbGUgPSAidXNlciIKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X2NvbnRlbnQgPSBfdHJ1bmNhdGUoaHVtYW5fdGV4dCkKICAgICAgICAgICAgICAgIGNvbnRpbnVlCgogICAgICAgICAgICBpZiBub3QgaW5fdHVybjoKICAgICAgICAgICAgICAgIGNvbnRpbnVlCgogICAgICAgICAgICAjIOKUgOKUgCBUb29sIHJlc3VsdCAodXNlciBtZXNzYWdlIHdpdGggbGlzdCBjb250ZW50KSDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgICAgICAgICAgaWYgZW50cnlfdHlwZSA9PSAidXNlciI6CiAgICAgICAgICAgICAgICBfZmx1c2hfZ3JvdXAoKQogICAgICAgICAgICAgICAgY29udGVudCA9IGVudHJ5LmdldCgibWVzc2FnZSIsIHt9KS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgICAgIGlmIGlzaW5zdGFuY2UoY29udGVudCwgbGlzdCk6CiAgICAgICAgICAgICAgICAgICAgdGV4dCA9IF90b29sX3Jlc3VsdF90ZXh0KGNvbnRlbnQpCiAgICAgICAgICAgICAgICAgICAgcGF5bG9hZCA9IHRleHQgaWYgdGV4dCBlbHNlIGpzb24uZHVtcHMoY29udGVudCkKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X3ZhbHVlID0ganNvbi5kdW1wcygKICAgICAgICAgICAgICAgICAgICAgICAgeyJyb2xlIjogInVzZXIiLCAiY29udGVudCI6IHBheWxvYWRbOjUwMF19LAogICAgICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X21pbWUgPSAiYXBwbGljYXRpb24vanNvbiIKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X3JvbGUgPSAidXNlciIKICAgICAgICAgICAgICAgICAgICBsYXN0X2lucHV0X2NvbnRlbnQgPSBfdHJ1bmNhdGUocGF5bG9hZCkKICAgICAgICAgICAgICAgIGNvbnRpbnVlCgogICAgICAgICAgICAjIOKUgOKUgCBOb24tYXNzaXN0YW50IGVudHJpZXMgKHByb2dyZXNzLCBzeXN0ZW0sIOKApikg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICAgICAgICAgIGlmIGVudHJ5X3R5cGUgIT0gImFzc2lzdGFudCI6CiAgICAgICAgICAgICAgICBjb250aW51ZQoKICAgICAgICAgICAgbXNnID0gZW50cnkuZ2V0KCJtZXNzYWdlIiwge30pCiAgICAgICAgICAgIHVzYWdlID0gbXNnLmdldCgidXNhZ2UiLCB7fSkKICAgICAgICAgICAgaWYgbm90IHVzYWdlOgogICAgICAgICAgICAgICAgY29udGludWUKCiAgICAgICAgICAgIG1zZ19pZCA9IG1zZy5nZXQoImlkIiwgIiIpIG9yIGlkKGVudHJ5KSAgIyBmYWxsIGJhY2sgdG8gb2JqZWN0IGlkCiAgICAgICAgICAgIG1vZGVsID0gbXNnLmdldCgibW9kZWwiLCAiY2xhdWRlIikKICAgICAgICAgICAgY29udGVudF9ibG9ja3MgPSBtc2cuZ2V0KCJjb250ZW50IiwgW10pCiAgICAgICAgICAgIHRzID0gZW50cnkuZ2V0KCJ0aW1lc3RhbXAiLCAiIikKCiAgICAgICAgICAgICMg4pSA4pSAIFN0YXJ0IGEgbmV3IGdyb3VwIG9yIGV4dGVuZCB0aGUgY3VycmVudCBvbmUg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICAgICAgICAgIGlmIG1zZ19pZCAhPSBjdXJyZW50X2dyb3VwX2lkOgogICAgICAgICAgICAgICAgX2ZsdXNoX2dyb3VwKCkKICAgICAgICAgICAgICAgIGN1cnJlbnRfZ3JvdXBfaWQgPSBtc2dfaWQKICAgICAgICAgICAgICAgIGdyb3VwX2ZpcnN0X3VzYWdlID0gdXNhZ2UKICAgICAgICAgICAgICAgIGdyb3VwX3RvdGFsX291dHB1dF90b2tlbnMgPSAwCiAgICAgICAgICAgICAgICBncm91cF90ZXh0X3BhcnRzID0gW10KICAgICAgICAgICAgICAgIGdyb3VwX3Rvb2xfdXNlX3BhcnRzID0gW10KICAgICAgICAgICAgICAgIGdyb3VwX21vZGVsID0gbW9kZWwKICAgICAgICAgICAgICAgIGdyb3VwX3RzID0gdHMKICAgICAgICAgICAgICAgICMgU25hcHNob3QgdGhlIGlucHV0IGNvbnRleHQgYXQgdGhlIHN0YXJ0IG9mIHRoaXMgQVBJIGNhbGwKICAgICAgICAgICAgICAgIGdyb3VwX2lucHV0X3NuYXBzaG90ID0gewogICAgICAgICAgICAgICAgICAgICJyb2xlIjogbGFzdF9pbnB1dF9yb2xlLAogICAgICAgICAgICAgICAgICAgICJjb250ZW50IjogbGFzdF9pbnB1dF9jb250ZW50LAogICAgICAgICAgICAgICAgICAgICJ2YWx1ZSI6IGxhc3RfaW5wdXRfdmFsdWUsCiAgICAgICAgICAgICAgICAgICAgIm1pbWUiOiBsYXN0X2lucHV0X21pbWUsCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAjIEFjY3VtdWxhdGUgb3V0cHV0IHRva2VucyBhbmQgY29udGVudCBibG9ja3MgZm9yIHRoaXMgZ3JvdXAKICAgICAgICAgICAgZ3JvdXBfdG90YWxfb3V0cHV0X3Rva2VucyArPSB1c2FnZS5nZXQoIm91dHB1dF90b2tlbnMiLCAwKQogICAgICAgICAgICBmb3IgYmxvY2sgaW4gY29udGVudF9ibG9ja3M6CiAgICAgICAgICAgICAgICBpZiBub3QgaXNpbnN0YW5jZShibG9jaywgZGljdCk6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIGlmIGJsb2NrLmdldCgidHlwZSIpID09ICJ0ZXh0IjoKICAgICAgICAgICAgICAgICAgICBncm91cF90ZXh0X3BhcnRzLmFwcGVuZChibG9jay5nZXQoInRleHQiLCAiIikpCiAgICAgICAgICAgICAgICBlbGlmIGJsb2NrLmdldCgidHlwZSIpID09ICJ0b29sX3VzZSI6CiAgICAgICAgICAgICAgICAgICAgZ3JvdXBfdG9vbF91c2VfcGFydHMuYXBwZW5kKAogICAgICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAibmFtZSI6IGJsb2NrLmdldCgibmFtZSIsICIiKSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJpbnB1dCI6IGJsb2NrLmdldCgiaW5wdXQiLCB7fSksCiAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgKQoKICAgICAgICBfZmx1c2hfZ3JvdXAoKQoKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBsb2cud2FybmluZygiRmFpbGVkIHRvIGV4dHJhY3QgTExNIHNwYW5zOiAlcyIsIGUpCgogICAgcmV0dXJuIHNwYW5zCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBPcGVuVGVsZW1ldHJ5IGltcG9ydHMgKGxhenkpCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgoKZGVmIF9vdGVsX2ltcG9ydHMoKToKICAgIGZyb20gb3BlbnRlbGVtZXRyeSBpbXBvcnQgdHJhY2UKICAgIGZyb20gb3BlbnRlbGVtZXRyeS5leHBvcnRlci5vdGxwLnByb3RvLmh0dHAudHJhY2VfZXhwb3J0ZXIgaW1wb3J0IE9UTFBTcGFuRXhwb3J0ZXIKICAgIGZyb20gb3BlbnRlbGVtZXRyeS5zZGsucmVzb3VyY2VzIGltcG9ydCBSZXNvdXJjZQogICAgZnJvbSBvcGVudGVsZW1ldHJ5LnNkay50cmFjZSBpbXBvcnQgU3BhbkNvbnRleHQsIFRyYWNlclByb3ZpZGVyCiAgICBmcm9tIG9wZW50ZWxlbWV0cnkuc2RrLnRyYWNlLmV4cG9ydCBpbXBvcnQgU2ltcGxlU3BhblByb2Nlc3NvcgogICAgZnJvbSBvcGVudGVsZW1ldHJ5LnRyYWNlIGltcG9ydCBOb25SZWNvcmRpbmdTcGFuLCBTcGFuS2luZCwgU3RhdHVzQ29kZSwgVHJhY2VGbGFncwoKICAgIHJldHVybiAoCiAgICAgICAgdHJhY2UsCiAgICAgICAgUmVzb3VyY2UsCiAgICAgICAgVHJhY2VyUHJvdmlkZXIsCiAgICAgICAgU3BhbkNvbnRleHQsCiAgICAgICAgU2ltcGxlU3BhblByb2Nlc3NvciwKICAgICAgICBPVExQU3BhbkV4cG9ydGVyLAogICAgICAgIFNwYW5LaW5kLAogICAgICAgIFRyYWNlRmxhZ3MsCiAgICAgICAgTm9uUmVjb3JkaW5nU3BhbiwKICAgICAgICBTdGF0dXNDb2RlLAogICAgKQoKCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiMgVG9vbCBzY2hlbWFzCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgpUT09MX1NDSEVNQVM6IGRpY3Rbc3RyLCBkaWN0XSA9IHsKICAgICJCYXNoIjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7CiAgICAgICAgICAgICJjb21tYW5kIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiZGVzY3JpcHRpb24iOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJ0aW1lb3V0IjogeyJ0eXBlIjogIm51bWJlciJ9LAogICAgICAgICAgICAicnVuX2luX2JhY2tncm91bmQiOiB7InR5cGUiOiAiYm9vbGVhbiJ9LAogICAgICAgIH0sCiAgICAgICAgInJlcXVpcmVkIjogWyJjb21tYW5kIl0sCiAgICB9LAogICAgIlJlYWQiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgImZpbGVfcGF0aCI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgIm9mZnNldCI6IHsidHlwZSI6ICJudW1iZXIifSwKICAgICAgICAgICAgImxpbWl0IjogeyJ0eXBlIjogIm51bWJlciJ9LAogICAgICAgIH0sCiAgICAgICAgInJlcXVpcmVkIjogWyJmaWxlX3BhdGgiXSwKICAgIH0sCiAgICAiRWRpdCI6IHsKICAgICAgICAidHlwZSI6ICJvYmplY3QiLAogICAgICAgICJwcm9wZXJ0aWVzIjogewogICAgICAgICAgICAiZmlsZV9wYXRoIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAib2xkX3N0cmluZyI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgIm5ld19zdHJpbmciOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJyZXBsYWNlX2FsbCI6IHsidHlwZSI6ICJib29sZWFuIn0sCiAgICAgICAgfSwKICAgICAgICAicmVxdWlyZWQiOiBbImZpbGVfcGF0aCIsICJvbGRfc3RyaW5nIiwgIm5ld19zdHJpbmciXSwKICAgIH0sCiAgICAiV3JpdGUiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgImZpbGVfcGF0aCI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgImNvbnRlbnQiOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgfSwKICAgICAgICAicmVxdWlyZWQiOiBbImZpbGVfcGF0aCIsICJjb250ZW50Il0sCiAgICB9LAogICAgIkdsb2IiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgInBhdHRlcm4iOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJwYXRoIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgIH0sCiAgICAgICAgInJlcXVpcmVkIjogWyJwYXR0ZXJuIl0sCiAgICB9LAogICAgIkdyZXAiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgInBhdHRlcm4iOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJwYXRoIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiZ2xvYiI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgInR5cGUiOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJvdXRwdXRfbW9kZSI6IHsKICAgICAgICAgICAgICAgICJ0eXBlIjogInN0cmluZyIsCiAgICAgICAgICAgICAgICAiZW51bSI6IFsiY29udGVudCIsICJmaWxlc193aXRoX21hdGNoZXMiLCAiY291bnQiXSwKICAgICAgICAgICAgfSwKICAgICAgICAgICAgImNvbnRleHQiOiB7InR5cGUiOiAibnVtYmVyIn0sCiAgICAgICAgfSwKICAgICAgICAicmVxdWlyZWQiOiBbInBhdHRlcm4iXSwKICAgIH0sCiAgICAiQWdlbnQiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgImRlc2NyaXB0aW9uIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAicHJvbXB0IjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAic3ViYWdlbnRfdHlwZSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgInJ1bl9pbl9iYWNrZ3JvdW5kIjogeyJ0eXBlIjogImJvb2xlYW4ifSwKICAgICAgICAgICAgImlzb2xhdGlvbiI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICB9LAogICAgICAgICJyZXF1aXJlZCI6IFsiZGVzY3JpcHRpb24iLCAicHJvbXB0Il0sCiAgICB9LAogICAgIlRhc2siOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgImRlc2NyaXB0aW9uIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAicHJvbXB0IjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAic3ViYWdlbnRfdHlwZSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgInJ1bl9pbl9iYWNrZ3JvdW5kIjogeyJ0eXBlIjogImJvb2xlYW4ifSwKICAgICAgICB9LAogICAgICAgICJyZXF1aXJlZCI6IFsiZGVzY3JpcHRpb24iLCAicHJvbXB0IiwgInN1YmFnZW50X3R5cGUiXSwKICAgIH0sCiAgICAiV2ViRmV0Y2giOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsidXJsIjogeyJ0eXBlIjogInN0cmluZyJ9LCAicHJvbXB0IjogeyJ0eXBlIjogInN0cmluZyJ9fSwKICAgICAgICAicmVxdWlyZWQiOiBbInVybCIsICJwcm9tcHQiXSwKICAgIH0sCiAgICAiV2ViU2VhcmNoIjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7CiAgICAgICAgICAgICJxdWVyeSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgImFsbG93ZWRfZG9tYWlucyI6IHsidHlwZSI6ICJhcnJheSIsICJpdGVtcyI6IHsidHlwZSI6ICJzdHJpbmcifX0sCiAgICAgICAgICAgICJibG9ja2VkX2RvbWFpbnMiOiB7InR5cGUiOiAiYXJyYXkiLCAiaXRlbXMiOiB7InR5cGUiOiAic3RyaW5nIn19LAogICAgICAgIH0sCiAgICAgICAgInJlcXVpcmVkIjogWyJxdWVyeSJdLAogICAgfSwKICAgICJOb3RlYm9va0VkaXQiOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAgICAgIm5vdGVib29rX3BhdGgiOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJuZXdfc291cmNlIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiY2VsbF9pZCI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgImNlbGxfdHlwZSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgImVkaXRfbW9kZSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICB9LAogICAgICAgICJyZXF1aXJlZCI6IFsibm90ZWJvb2tfcGF0aCIsICJuZXdfc291cmNlIl0sCiAgICB9LAogICAgIlNraWxsIjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7InNraWxsIjogeyJ0eXBlIjogInN0cmluZyJ9LCAiYXJncyI6IHsidHlwZSI6ICJzdHJpbmcifX0sCiAgICAgICAgInJlcXVpcmVkIjogWyJza2lsbCJdLAogICAgfSwKICAgICJBc2tVc2VyUXVlc3Rpb24iOiB7CiAgICAgICAgInR5cGUiOiAib2JqZWN0IiwKICAgICAgICAicHJvcGVydGllcyI6IHsicXVlc3Rpb25zIjogeyJ0eXBlIjogImFycmF5In19LAogICAgICAgICJyZXF1aXJlZCI6IFsicXVlc3Rpb25zIl0sCiAgICB9LAogICAgIkVudGVyUGxhbk1vZGUiOiB7InR5cGUiOiAib2JqZWN0IiwgInByb3BlcnRpZXMiOiB7fSwgInJlcXVpcmVkIjogW119LAogICAgIkV4aXRQbGFuTW9kZSI6IHsidHlwZSI6ICJvYmplY3QiLCAicHJvcGVydGllcyI6IHt9LCAicmVxdWlyZWQiOiBbXX0sCiAgICAiVGFza0NyZWF0ZSI6IHsKICAgICAgICAidHlwZSI6ICJvYmplY3QiLAogICAgICAgICJwcm9wZXJ0aWVzIjogewogICAgICAgICAgICAic3ViamVjdCI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICAgICAgImRlc2NyaXB0aW9uIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiYWN0aXZlRm9ybSI6IHsidHlwZSI6ICJzdHJpbmcifSwKICAgICAgICB9LAogICAgICAgICJyZXF1aXJlZCI6IFsic3ViamVjdCIsICJkZXNjcmlwdGlvbiJdLAogICAgfSwKICAgICJUYXNrVXBkYXRlIjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7CiAgICAgICAgICAgICJ0YXNrSWQiOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJzdGF0dXMiOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgICAgICJzdWJqZWN0IjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiZGVzY3JpcHRpb24iOiB7InR5cGUiOiAic3RyaW5nIn0sCiAgICAgICAgfSwKICAgICAgICAicmVxdWlyZWQiOiBbInRhc2tJZCJdLAogICAgfSwKICAgICJUYXNrR2V0IjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7InRhc2tJZCI6IHsidHlwZSI6ICJzdHJpbmcifX0sCiAgICAgICAgInJlcXVpcmVkIjogWyJ0YXNrSWQiXSwKICAgIH0sCiAgICAiVGFza0xpc3QiOiB7InR5cGUiOiAib2JqZWN0IiwgInByb3BlcnRpZXMiOiB7fSwgInJlcXVpcmVkIjogW119LAogICAgIlRhc2tTdG9wIjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7InRhc2tfaWQiOiB7InR5cGUiOiAic3RyaW5nIn19LAogICAgICAgICJyZXF1aXJlZCI6IFtdLAogICAgfSwKICAgICJUYXNrT3V0cHV0IjogewogICAgICAgICJ0eXBlIjogIm9iamVjdCIsCiAgICAgICAgInByb3BlcnRpZXMiOiB7CiAgICAgICAgICAgICJ0YXNrX2lkIjogeyJ0eXBlIjogInN0cmluZyJ9LAogICAgICAgICAgICAiYmxvY2siOiB7InR5cGUiOiAiYm9vbGVhbiJ9LAogICAgICAgICAgICAidGltZW91dCI6IHsidHlwZSI6ICJudW1iZXIifSwKICAgICAgICB9LAogICAgICAgICJyZXF1aXJlZCI6IFsidGFza19pZCJdLAogICAgfSwKfQoKIyBUb29scyB0aGF0IG1hcCB0byBSRVRSSUVWRVIgc3BhbiBraW5kICh3ZWIgcmV0cmlldmFsIG9wZXJhdGlvbnMpClJFVFJJRVZFUl9UT09MUyA9IHsiV2ViU2VhcmNoIiwgIldlYkZldGNoIn0KCl9NQVhfQVRUUl9CWVRFUyA9IDgxOTIKCgpkZWYgX3RydW5jYXRlKHZhbHVlOiBBbnkpIC0+IHN0cjoKICAgIHMgPSBqc29uLmR1bXBzKHZhbHVlKSBpZiBub3QgaXNpbnN0YW5jZSh2YWx1ZSwgc3RyKSBlbHNlIHZhbHVlCiAgICBlbmNvZGVkID0gcy5lbmNvZGUoInV0Zi04IikKICAgIGlmIGxlbihlbmNvZGVkKSA+IF9NQVhfQVRUUl9CWVRFUzoKICAgICAgICBzID0gKAogICAgICAgICAgICBlbmNvZGVkWzpfTUFYX0FUVFJfQllURVNdLmRlY29kZSgidXRmLTgiLCBlcnJvcnM9Imlnbm9yZSIpCiAgICAgICAgICAgICsgIi4uLlt0cnVuY2F0ZWRdIgogICAgICAgICkKICAgIHJldHVybiBzCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBGaXhlZFNwYW5JZEdlbmVyYXRvciDigJQgZm9yY2VzIHRoZSBwcmUtZ2VuZXJhdGVkIHJvb3Qgc3BhbiBJRAojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKCmRlZiBfbWFrZV9maXhlZF9pZF9nZW5lcmF0b3IoZm9yY2VkX3NwYW5faWRfaGV4OiBzdHIpOgogICAgaW1wb3J0IHNlY3JldHMKCiAgICBmcm9tIG9wZW50ZWxlbWV0cnkuc2RrLnRyYWNlLmlkX2dlbmVyYXRvciBpbXBvcnQgSWRHZW5lcmF0b3IKCiAgICBmb3JjZWRfaW50ID0gaW50KGZvcmNlZF9zcGFuX2lkX2hleCwgMTYpCgogICAgY2xhc3MgRml4ZWRTcGFuSWRHZW5lcmF0b3IoSWRHZW5lcmF0b3IpOgogICAgICAgIGRlZiBfX2luaXRfXyhzZWxmKToKICAgICAgICAgICAgc2VsZi5fdXNlZCA9IEZhbHNlCgogICAgICAgIGRlZiBnZW5lcmF0ZV9zcGFuX2lkKHNlbGYpIC0+IGludDoKICAgICAgICAgICAgaWYgbm90IHNlbGYuX3VzZWQ6CiAgICAgICAgICAgICAgICBzZWxmLl91c2VkID0gVHJ1ZQogICAgICAgICAgICAgICAgcmV0dXJuIGZvcmNlZF9pbnQKICAgICAgICAgICAgcmV0dXJuIGludChzZWNyZXRzLnRva2VuX2hleCg4KSwgMTYpCgogICAgICAgIGRlZiBnZW5lcmF0ZV90cmFjZV9pZChzZWxmKSAtPiBpbnQ6CiAgICAgICAgICAgIHJldHVybiBpbnQoc2VjcmV0cy50b2tlbl9oZXgoMTYpLCAxNikKCiAgICByZXR1cm4gRml4ZWRTcGFuSWRHZW5lcmF0b3IoKQoKCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiMgT1RMUCBleHBvcnQgaGVscGVyCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgoKZGVmIF9idWlsZF9hbmRfZXhwb3J0X3NwYW5zKAogICAgY29uZmlnOiBkaWN0LAogICAgc2Vzc2lvbl9pZDogc3RyLAogICAgdXNlcm5hbWU6IHN0ciwKICAgIHNwYW5fcmVjb3JkczogbGlzdFtkaWN0XSwKKSAtPiBOb25lOgogICAgIiIiQ3JlYXRlIHNwYW5zIGZyb20gcmVjb3JkcyBhbmQgZXhwb3J0IHZpYSBPVExQIEhUVFAuIiIiCiAgICAoCiAgICAgICAgdHJhY2UsCiAgICAgICAgUmVzb3VyY2UsCiAgICAgICAgVHJhY2VyUHJvdmlkZXIsCiAgICAgICAgU3BhbkNvbnRleHQsCiAgICAgICAgU2ltcGxlU3BhblByb2Nlc3NvciwKICAgICAgICBPVExQU3BhbkV4cG9ydGVyLAogICAgICAgIFNwYW5LaW5kLAogICAgICAgIFRyYWNlRmxhZ3MsCiAgICAgICAgTm9uUmVjb3JkaW5nU3BhbiwKICAgICAgICBTdGF0dXNDb2RlLAogICAgKSA9IF9vdGVsX2ltcG9ydHMoKQoKICAgIHJlc291cmNlID0gUmVzb3VyY2UuY3JlYXRlKAogICAgICAgIHsKICAgICAgICAgICAgInNlcnZpY2UubmFtZSI6ICJjbGF1ZGUtY29kZSIsCiAgICAgICAgICAgICJhcnRodXIudGFzayI6IGNvbmZpZ1sidGFza19pZCJdLAogICAgICAgICAgICAiYXJ0aHVyLnNlc3Npb24iOiBzZXNzaW9uX2lkLAogICAgICAgICAgICAiYXJ0aHVyLnVzZXIiOiB1c2VybmFtZSwKICAgICAgICB9LAogICAgKQoKICAgIGZvciByZWMgaW4gc3Bhbl9yZWNvcmRzOgogICAgICAgICMgRWFjaCBzcGFuIGdldHMgaXRzIG93biBleHBvcnRlciBzbyB0aGF0IHByb3ZpZGVyLnNodXRkb3duKCkgb24gc3BhbiBOCiAgICAgICAgIyBkb2Vzbid0IG1hcmsgdGhlIHNoYXJlZCBleHBvcnRlciBhcyBjbG9zZWQgYmVmb3JlIHNwYW4gTisxIGlzIHNlbnQuCiAgICAgICAgZXhwb3J0ZXIgPSBPVExQU3BhbkV4cG9ydGVyKAogICAgICAgICAgICBlbmRwb2ludD1jb25maWdbImVuZHBvaW50Il0sCiAgICAgICAgICAgIGhlYWRlcnM9eyJBdXRob3JpemF0aW9uIjogZiJCZWFyZXIge2NvbmZpZ1snYXBpX2tleSddfSJ9LAogICAgICAgICkKICAgICAgICAjIFJlc29sdmUgTm9uZSBraW5kICh1c2VkIGZvciBMTE0gc3BhbnMgc2V0IGJ5IGNhbGxlcikKICAgICAgICBraW5kID0gcmVjLmdldCgia2luZCIpIG9yIFNwYW5LaW5kLkNMSUVOVAoKICAgICAgICBpZF9nZW5lcmF0b3IgPSBOb25lCiAgICAgICAgaWYgcmVjLmdldCgiZm9yY2Vfc3Bhbl9pZCIpOgogICAgICAgICAgICBpZF9nZW5lcmF0b3IgPSBfbWFrZV9maXhlZF9pZF9nZW5lcmF0b3IocmVjWyJzcGFuX2lkX2hleCJdKQoKICAgICAgICBrd2FyZ3MgPSB7InJlc291cmNlIjogcmVzb3VyY2V9CiAgICAgICAgaWYgaWRfZ2VuZXJhdG9yOgogICAgICAgICAgICBrd2FyZ3NbImlkX2dlbmVyYXRvciJdID0gaWRfZ2VuZXJhdG9yCgogICAgICAgIHByb3ZpZGVyID0gVHJhY2VyUHJvdmlkZXIoKiprd2FyZ3MpCiAgICAgICAgcHJvdmlkZXIuYWRkX3NwYW5fcHJvY2Vzc29yKFNpbXBsZVNwYW5Qcm9jZXNzb3IoZXhwb3J0ZXIpKQogICAgICAgIHRyYWNlciA9IHByb3ZpZGVyLmdldF90cmFjZXIoImNsYXVkZS1jb2RlLXRyYWNlciIpCgogICAgICAgIGN0eCA9IE5vbmUKICAgICAgICBpZiByZWMuZ2V0KCJwYXJlbnRfc3Bhbl9pZF9oZXgiKToKICAgICAgICAgICAgcGFyZW50X3NjID0gU3BhbkNvbnRleHQoCiAgICAgICAgICAgICAgICB0cmFjZV9pZD1pbnQocmVjWyJ0cmFjZV9pZF9oZXgiXSwgMTYpLAogICAgICAgICAgICAgICAgc3Bhbl9pZD1pbnQocmVjWyJwYXJlbnRfc3Bhbl9pZF9oZXgiXSwgMTYpLAogICAgICAgICAgICAgICAgaXNfcmVtb3RlPVRydWUsCiAgICAgICAgICAgICAgICB0cmFjZV9mbGFncz1UcmFjZUZsYWdzKFRyYWNlRmxhZ3MuU0FNUExFRCksCiAgICAgICAgICAgICkKICAgICAgICAgICAgY3R4ID0gdHJhY2Uuc2V0X3NwYW5faW5fY29udGV4dChOb25SZWNvcmRpbmdTcGFuKHBhcmVudF9zYykpCgogICAgICAgIHNwYW4gPSB0cmFjZXIuc3RhcnRfc3BhbigKICAgICAgICAgICAgbmFtZT1yZWNbIm5hbWUiXSwKICAgICAgICAgICAgY29udGV4dD1jdHgsCiAgICAgICAgICAgIGtpbmQ9a2luZCwKICAgICAgICAgICAgc3RhcnRfdGltZT1yZWNbInN0YXJ0X25zIl0sCiAgICAgICAgKQoKICAgICAgICAjIFBhdGNoIHRyYWNlX2lkIGlmIHByb3ZpZGVyIGdlbmVyYXRlZCBhIGRpZmZlcmVudCBvbmUKICAgICAgICB0cnk6CiAgICAgICAgICAgIHNjID0gc3Bhbi5nZXRfc3Bhbl9jb250ZXh0KCkKICAgICAgICAgICAgZGVzaXJlZCA9IGludChyZWNbInRyYWNlX2lkX2hleCJdLCAxNikKICAgICAgICAgICAgaWYgc2MudHJhY2VfaWQgIT0gZGVzaXJlZDoKICAgICAgICAgICAgICAgIHNwYW4uX2NvbnRleHQgPSBTcGFuQ29udGV4dCgKICAgICAgICAgICAgICAgICAgICB0cmFjZV9pZD1kZXNpcmVkLAogICAgICAgICAgICAgICAgICAgIHNwYW5faWQ9c2Muc3Bhbl9pZCwKICAgICAgICAgICAgICAgICAgICBpc19yZW1vdGU9c2MuaXNfcmVtb3RlLAogICAgICAgICAgICAgICAgICAgIHRyYWNlX2ZsYWdzPXNjLnRyYWNlX2ZsYWdzLAogICAgICAgICAgICAgICAgKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIHBhc3MKCiAgICAgICAgc3Bhbi5zZXRfYXR0cmlidXRlKCJhcnRodXJfc3Bhbl92ZXJzaW9uIiwgImFydGh1cl9zcGFuX3YxIikKICAgICAgICBmb3IgaywgdiBpbiByZWMuZ2V0KCJhdHRyaWJ1dGVzIiwge30pLml0ZW1zKCk6CiAgICAgICAgICAgIHNwYW4uc2V0X2F0dHJpYnV0ZShrLCB2KQoKICAgICAgICBpZiByZWMuZ2V0KCJlcnJvciIpOgogICAgICAgICAgICBzcGFuLnNldF9zdGF0dXMoU3RhdHVzQ29kZS5FUlJPUiwgZGVzY3JpcHRpb249cmVjLmdldCgiZXJyb3JfbXNnIiwgIiIpKQoKICAgICAgICBzcGFuLmVuZChlbmRfdGltZT1yZWNbImVuZF9ucyJdKQogICAgICAgIHByb3ZpZGVyLnNodXRkb3duKCkKCgojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQojIFR1cm4gY29tcGxldGlvbgojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKCmRlZiBfZW1pdF9wZW5kaW5nX2xsbV9zcGFucygKICAgIHN0YXRlOiBkaWN0LAogICAgdHJhbnNjcmlwdF9wYXRoOiBPcHRpb25hbFtzdHJdLAogICAgY29uZmlnOiBkaWN0LAopIC0+IE5vbmU6CiAgICAiIiJFbWl0IGFueSBuZXcgTExNIHNwYW5zIGZyb20gdGhlIHRyYW5zY3JpcHQgdGhhdCBoYXZlbid0IGJlZW4gc2VudCB5ZXQuCgogICAgQ2FsbGVkIGZyb20gaGFuZGxlX3Bvc3RfdG9vbCAocmVhbC10aW1lLCBhZnRlciBlYWNoIHRvb2wgcmVzcG9uc2UpIGFuZAogICAgaGFuZGxlX3N0b3AgKGNhdGNoZXMgdGhlIGZpbmFsIExMTSByZXNwb25zZSBhZnRlciB0aGUgbGFzdCB0b29sIGNhbGwpLgogICAgVXBkYXRlcyBzdGF0ZS5jdXJyZW50X3RyYWNlIGluLXBsYWNlIHNvIHRoZSBjYWxsZXIgbXVzdCBzYXZlIHN0YXRlIGFmdGVyd2FyZHMuCiAgICAiIiIKICAgIGN1cnJlbnRfdHJhY2UgPSBzdGF0ZS5nZXQoImN1cnJlbnRfdHJhY2UiKQogICAgaWYgbm90IGN1cnJlbnRfdHJhY2Ugb3Igbm90IHRyYW5zY3JpcHRfcGF0aDoKICAgICAgICByZXR1cm4KCiAgICAoXywgXywgXywgXywgXywgXywgU3BhbktpbmQsIF8sIF8sIF8pID0gX290ZWxfaW1wb3J0cygpCgogICAgYWxsX2xsbV9zcGFucyA9IF9leHRyYWN0X2xsbV9zcGFuc19mb3JfdHVybigKICAgICAgICB0cmFuc2NyaXB0X3BhdGgsCiAgICAgICAgY3VycmVudF90cmFjZS5nZXQoImh1bWFuX2NvdW50X2F0X3N0YXJ0IiwgMCksCiAgICAgICAgY3VycmVudF90cmFjZVsidHJhY2VfaWQiXSwKICAgICAgICBjdXJyZW50X3RyYWNlWyJyb290X3NwYW5faWQiXSwKICAgICkKCiAgICBlbWl0dGVkID0gY3VycmVudF90cmFjZS5nZXQoImVtaXR0ZWRfbGxtX3NwYW5fY291bnQiLCAwKQogICAgbmV3X3NwYW5zID0gYWxsX2xsbV9zcGFuc1tlbWl0dGVkOl0KCiAgICAjIEFsd2F5cyBrZWVwIGxhc3RfbGxtX291dHB1dCBpbiBzeW5jIHdpdGggdGhlIGxhc3Qga25vd24gYXNzaXN0YW50IG1lc3NhZ2UsCiAgICAjIGV2ZW4gaWYgdGhlcmUgYXJlIG5vIG5ldyBzcGFucyB0byBlbWl0IChlLmcuIGFsbCB3ZXJlIGVtaXR0ZWQgYnkgcG9zdF90b29sCiAgICAjIGFuZCB0aGUgZmluYWwgdGV4dCByZXNwb25zZSB3YXMgYWxyZWFkeSB0aGUgbGFzdCBvbmUgcHJvY2Vzc2VkKS4KICAgIGlmIGFsbF9sbG1fc3BhbnM6CiAgICAgICAgY3VycmVudF90cmFjZVsibGFzdF9sbG1fb3V0cHV0Il0gPSBhbGxfbGxtX3NwYW5zWy0xXVsiYXR0cmlidXRlcyJdLmdldCgKICAgICAgICAgICAgIm91dHB1dC52YWx1ZSIsICIiCiAgICAgICAgKQoKICAgIGlmIG5vdCBuZXdfc3BhbnM6CiAgICAgICAgcmV0dXJuCgogICAgZm9yIHNwIGluIG5ld19zcGFuczoKICAgICAgICBzcFsia2luZCJdID0gU3BhbktpbmQuQ0xJRU5UCgogICAgX2J1aWxkX2FuZF9leHBvcnRfc3BhbnMoCiAgICAgICAgY29uZmlnPWNvbmZpZywKICAgICAgICBzZXNzaW9uX2lkPXN0YXRlWyJzZXNzaW9uX2lkIl0sCiAgICAgICAgdXNlcm5hbWU9c3RhdGUuZ2V0KCJ1c2VybmFtZSIsICJ1bmtub3duIiksCiAgICAgICAgc3Bhbl9yZWNvcmRzPW5ld19zcGFucywKICAgICkKCiAgICBjdXJyZW50X3RyYWNlWyJlbWl0dGVkX2xsbV9zcGFuX2NvdW50Il0gPSBlbWl0dGVkICsgbGVuKG5ld19zcGFucykKCgpkZWYgX2NvbXBsZXRlX3R1cm4oCiAgICBzdGF0ZTogZGljdCwKICAgIGNvbmZpZzogZGljdCwKICAgIHRyYW5zY3JpcHRfcGF0aDogT3B0aW9uYWxbc3RyXSwKICAgIGVuZF9uczogaW50LAopIC0+IE5vbmU6CiAgICAiIiJTZW5kIHRoZSBDSEFJTiByb290IHNwYW4gZm9yIHRoZSBjdXJyZW50IHR1cm4ncyB0cmFjZS4KCiAgICBMTE0gc3BhbnMgYXJlIGVtaXR0ZWQgaW4gcmVhbC10aW1lIGZyb20gX2VtaXRfcGVuZGluZ19sbG1fc3BhbnMgKGNhbGxlZAogICAgZnJvbSBoYW5kbGVfcG9zdF90b29sIGFuZCBoYW5kbGVfc3RvcCksIHNvIHRoZXkgZG8gbm90IG5lZWQgdG8gYmUgcmUtc2VudAogICAgaGVyZS4gIFRoZSByb290IHNwYW4ncyBvdXRwdXQudmFsdWUgaXMgdGFrZW4gZnJvbSB0aGUgbGFzdCBMTE0gb3V0cHV0CiAgICBhbHJlYWR5IHRyYWNrZWQgaW4gc3RhdGUuCiAgICAiIiIKICAgIGN1cnJlbnRfdHJhY2UgPSBzdGF0ZS5nZXQoImN1cnJlbnRfdHJhY2UiKQogICAgaWYgbm90IGN1cnJlbnRfdHJhY2U6CiAgICAgICAgcmV0dXJuCgogICAgKF8sIF8sIF8sIF8sIF8sIF8sIFNwYW5LaW5kLCBfLCBfLCBfKSA9IF9vdGVsX2ltcG9ydHMoKQoKICAgIHRyYWNlX2lkID0gY3VycmVudF90cmFjZVsidHJhY2VfaWQiXQogICAgcm9vdF9zcGFuX2lkID0gY3VycmVudF90cmFjZVsicm9vdF9zcGFuX2lkIl0KICAgIHR1cm5fc3RhcnRfbnMgPSBjdXJyZW50X3RyYWNlWyJ0dXJuX3N0YXJ0X25zIl0KICAgIHR1cm5fbnVtYmVyID0gY3VycmVudF90cmFjZS5nZXQoInR1cm5fbnVtYmVyIiwgMSkKICAgIHByb21wdF9wcmV2aWV3ID0gY3VycmVudF90cmFjZS5nZXQoInByb21wdF9wcmV2aWV3IiwgIiIpCiAgICBmaW5hbF9vdXRwdXQgPSBjdXJyZW50X3RyYWNlLmdldCgibGFzdF9sbG1fb3V0cHV0IiwgIiIpCiAgICAjIEZvciB0aGUgZmlyc3QgdHVybiBvZiBhIHN1YmFnZW50IHRoZSBDSEFJTiBzcGFuIGlzIGEgY2hpbGQgb2YgdGhlIHBhcmVudCdzCiAgICAjIEFnZW50IHRvb2wgc3BhbiwgbWFraW5nIGFsbCBzdWJhZ2VudCB3b3JrIHZpc2libGUgaW5zaWRlIHRoZSBwYXJlbnQgdHJhY2UuCiAgICBwYXJlbnRfYWdlbnRfc3Bhbl9pZCA9IGN1cnJlbnRfdHJhY2UuZ2V0KCJwYXJlbnRfYWdlbnRfc3Bhbl9pZCIpCgogICAgIyBSb290IENIQUlOIHNwYW4KICAgIHVzZXJuYW1lID0gc3RhdGUuZ2V0KCJ1c2VybmFtZSIsICJ1bmtub3duIikKICAgIHJvb3RfYXR0cnM6IGRpY3Rbc3RyLCBBbnldID0gewogICAgICAgICJvcGVuaW5mZXJlbmNlLnNwYW4ua2luZCI6ICJDSEFJTiIsCiAgICAgICAgInNlc3Npb24uaWQiOiBzdGF0ZVsic2Vzc2lvbl9pZCJdLAogICAgICAgICJ1c2VyLmlkIjogdXNlcm5hbWUsCiAgICAgICAgImFydGh1ci50dXJuX251bWJlciI6IHR1cm5fbnVtYmVyLAogICAgfQogICAgaWYgcHJvbXB0X3ByZXZpZXc6CiAgICAgICAgcm9vdF9hdHRyc1siaW5wdXQudmFsdWUiXSA9IHByb21wdF9wcmV2aWV3CiAgICAgICAgcm9vdF9hdHRyc1siaW5wdXQubWltZV90eXBlIl0gPSAidGV4dC9wbGFpbiIKICAgIGlmIGZpbmFsX291dHB1dDoKICAgICAgICByb290X2F0dHJzWyJvdXRwdXQudmFsdWUiXSA9IGZpbmFsX291dHB1dAogICAgICAgIHJvb3RfYXR0cnNbIm91dHB1dC5taW1lX3R5cGUiXSA9ICJ0ZXh0L3BsYWluIgoKICAgIF9idWlsZF9hbmRfZXhwb3J0X3NwYW5zKAogICAgICAgIGNvbmZpZz1jb25maWcsCiAgICAgICAgc2Vzc2lvbl9pZD1zdGF0ZVsic2Vzc2lvbl9pZCJdLAogICAgICAgIHVzZXJuYW1lPXVzZXJuYW1lLAogICAgICAgIHNwYW5fcmVjb3Jkcz1bCiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICJ0cmFjZV9pZF9oZXgiOiB0cmFjZV9pZCwKICAgICAgICAgICAgICAgICJzcGFuX2lkX2hleCI6IHJvb3Rfc3Bhbl9pZCwKICAgICAgICAgICAgICAgICJwYXJlbnRfc3Bhbl9pZF9oZXgiOiBwYXJlbnRfYWdlbnRfc3Bhbl9pZCwKICAgICAgICAgICAgICAgICJuYW1lIjogImNsYXVkZS1jb2RlLXR1cm4iLAogICAgICAgICAgICAgICAgImtpbmQiOiBTcGFuS2luZC5JTlRFUk5BTCwKICAgICAgICAgICAgICAgICJzdGFydF9ucyI6IHR1cm5fc3RhcnRfbnMsCiAgICAgICAgICAgICAgICAiZW5kX25zIjogZW5kX25zLAogICAgICAgICAgICAgICAgImF0dHJpYnV0ZXMiOiByb290X2F0dHJzLAogICAgICAgICAgICAgICAgImZvcmNlX3NwYW5faWQiOiBUcnVlLAogICAgICAgICAgICB9LAogICAgICAgIF0sCiAgICApCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBUb29sIHNwYW4gcmVjb3JkIGJ1aWxkZXIgKHNoYXJlZCBieSBwb3N0X3Rvb2wgYW5kIHBvc3RfdG9vbF9mYWlsdXJlKQojIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKCmRlZiBfYnVpbGRfdG9vbF9zcGFuX3JlY29yZCgKICAgIHRvb2xfbmFtZTogc3RyLAogICAgdG9vbF9pbnB1dDogQW55LAogICAgdG9vbF9yZXNwb25zZTogQW55LAogICAgc3RhcnRfbnM6IGludCwKICAgIGVuZF9uczogaW50LAogICAgdHJhY2VfaWQ6IHN0ciwKICAgIHJvb3Rfc3Bhbl9pZDogc3RyLAogICAgaXNfZmFpbHVyZTogYm9vbCA9IEZhbHNlLAogICAgZXJyb3JfbXNnOiBzdHIgPSAiIiwKICAgIHNwYW5faWQ6IE9wdGlvbmFsW3N0cl0gPSBOb25lLAopIC0+IGRpY3Q6CiAgICAiIiJCdWlsZCBhIHNwYW4gcmVjb3JkIGRpY3QgZm9yIGEgdG9vbCBjYWxsIChzdWNjZXNzIG9yIGZhaWx1cmUpLgoKICAgIFJldHVybnMgYSByZWNvcmQgc3VpdGFibGUgZm9yIHBhc3NpbmcgdG8gX2J1aWxkX2FuZF9leHBvcnRfc3BhbnMuCiAgICBUaGUga2luZCBmaWVsZCBpcyBsZWZ0IGFzIE5vbmUgc28gX2J1aWxkX2FuZF9leHBvcnRfc3BhbnMgZGVmYXVsdHMgdG8KICAgIFNwYW5LaW5kLkNMSUVOVC4KICAgICIiIgogICAgaWYgdG9vbF9uYW1lIGluIFJFVFJJRVZFUl9UT09MUzoKICAgICAgICBzcGFuX2tpbmRfc3RyID0gIlJFVFJJRVZFUiIKICAgIGVsaWYgdG9vbF9uYW1lIGluICgiQWdlbnQiLCAiVGFzayIpOgogICAgICAgIHNwYW5fa2luZF9zdHIgPSAiQUdFTlQiCiAgICBlbHNlOgogICAgICAgIHNwYW5fa2luZF9zdHIgPSAiVE9PTCIKCiAgICBpbnB1dF92YWx1ZSA9ICgKICAgICAgICBqc29uLmR1bXBzKHRvb2xfaW5wdXQpIGlmIG5vdCBpc2luc3RhbmNlKHRvb2xfaW5wdXQsIHN0cikgZWxzZSB0b29sX2lucHV0CiAgICApCiAgICBvdXRwdXRfc3RyID0gKAogICAgICAgIGpzb24uZHVtcHModG9vbF9yZXNwb25zZSkKICAgICAgICBpZiBub3QgaXNpbnN0YW5jZSh0b29sX3Jlc3BvbnNlLCBzdHIpCiAgICAgICAgZWxzZSB0b29sX3Jlc3BvbnNlCiAgICApCgogICAgaWYgaXNfZmFpbHVyZSBhbmQgZXJyb3JfbXNnOgogICAgICAgIG91dHB1dF92YWx1ZSA9IGYiRVJST1I6IHtlcnJvcl9tc2d9XG57b3V0cHV0X3N0cn0iCiAgICBlbHNlOgogICAgICAgIG91dHB1dF92YWx1ZSA9IG91dHB1dF9zdHIKCiAgICBhdHRyczogZGljdFtzdHIsIEFueV0gPSB7CiAgICAgICAgIm9wZW5pbmZlcmVuY2Uuc3Bhbi5raW5kIjogc3Bhbl9raW5kX3N0ciwKICAgICAgICAidG9vbC5uYW1lIjogdG9vbF9uYW1lLAogICAgICAgICJpbnB1dC52YWx1ZSI6IGlucHV0X3ZhbHVlLAogICAgICAgICJpbnB1dC5taW1lX3R5cGUiOiAiYXBwbGljYXRpb24vanNvbiIsCiAgICAgICAgIm91dHB1dC52YWx1ZSI6IG91dHB1dF92YWx1ZSwKICAgICAgICAib3V0cHV0Lm1pbWVfdHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIiwKICAgIH0KCiAgICBzY2hlbWEgPSBUT09MX1NDSEVNQVMuZ2V0KHRvb2xfbmFtZSkKICAgIGlmIHNjaGVtYToKICAgICAgICBhdHRyc1sidG9vbC5qc29uX3NjaGVtYSJdID0ganNvbi5kdW1wcyhzY2hlbWEpCgogICAgIyBSRVRSSUVWRVItc3BlY2lmaWMgYXR0cmlidXRlcyBwZXIgT3BlbkluZmVyZW5jZSBzcGVjCiAgICBpZiBzcGFuX2tpbmRfc3RyID09ICJSRVRSSUVWRVIiOgogICAgICAgIGlmIHRvb2xfbmFtZSA9PSAiV2ViU2VhcmNoIjoKICAgICAgICAgICAgcXVlcnkgPSB0b29sX2lucHV0LmdldCgicXVlcnkiLCAiIikgaWYgaXNpbnN0YW5jZSh0b29sX2lucHV0LCBkaWN0KSBlbHNlICIiCiAgICAgICAgICAgIGF0dHJzWyJpbnB1dC52YWx1ZSJdID0gcXVlcnkKICAgICAgICAgICAgYXR0cnNbImlucHV0Lm1pbWVfdHlwZSJdID0gInRleHQvcGxhaW4iCiAgICAgICAgZWxpZiB0b29sX25hbWUgPT0gIldlYkZldGNoIjoKICAgICAgICAgICAgdXJsID0gdG9vbF9pbnB1dC5nZXQoInVybCIsICIiKSBpZiBpc2luc3RhbmNlKHRvb2xfaW5wdXQsIGRpY3QpIGVsc2UgIiIKICAgICAgICAgICAgYXR0cnNbImlucHV0LnZhbHVlIl0gPSB1cmwKICAgICAgICAgICAgYXR0cnNbImlucHV0Lm1pbWVfdHlwZSJdID0gInRleHQvcGxhaW4iCgogICAgICAgICMgRmlyc3QgcmV0cmlldmFsIGRvY3VtZW50IGNvbnRlbnQKICAgICAgICBpZiBpc2luc3RhbmNlKHRvb2xfcmVzcG9uc2UsIHN0cik6CiAgICAgICAgICAgIGRvY19jb250ZW50ID0gX3RydW5jYXRlKHRvb2xfcmVzcG9uc2UpCiAgICAgICAgZWxzZToKICAgICAgICAgICAgZG9jX2NvbnRlbnQgPSBfdHJ1bmNhdGUoanNvbi5kdW1wcyh0b29sX3Jlc3BvbnNlKSkKICAgICAgICBhdHRyc1sicmV0cmlldmFsLmRvY3VtZW50cy4wLmRvY3VtZW50LmNvbnRlbnQiXSA9IGRvY19jb250ZW50CgogICAgcmVjOiBkaWN0W3N0ciwgQW55XSA9IHsKICAgICAgICAidHJhY2VfaWRfaGV4IjogdHJhY2VfaWQsCiAgICAgICAgInNwYW5faWRfaGV4Ijogc3Bhbl9pZCBvciBfbmV3X3NwYW5faWQoKSwKICAgICAgICAicGFyZW50X3NwYW5faWRfaGV4Ijogcm9vdF9zcGFuX2lkLAogICAgICAgICJuYW1lIjogdG9vbF9uYW1lLAogICAgICAgICJraW5kIjogTm9uZSwgICMgZGVmYXVsdHMgdG8gU3BhbktpbmQuQ0xJRU5UIGluIF9idWlsZF9hbmRfZXhwb3J0X3NwYW5zCiAgICAgICAgInN0YXJ0X25zIjogc3RhcnRfbnMsCiAgICAgICAgImVuZF9ucyI6IGVuZF9ucywKICAgICAgICAiYXR0cmlidXRlcyI6IGF0dHJzLAogICAgICAgICJmb3JjZV9zcGFuX2lkIjogc3Bhbl9pZCBpcyBub3QgTm9uZSwKICAgIH0KCiAgICBpZiBpc19mYWlsdXJlOgogICAgICAgIHJlY1siZXJyb3IiXSA9IFRydWUKICAgICAgICByZWNbImVycm9yX21zZyJdID0gZXJyb3JfbXNnCgogICAgcmV0dXJuIHJlYwoKCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCiMgSGFuZGxlcnMKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KCgpkZWYgaGFuZGxlX3VzZXJfcHJvbXB0X3N1Ym1pdChkYXRhOiBkaWN0LCBjb25maWc6IGRpY3QpIC0+IE5vbmU6CiAgICAiIiJIYW5kbGUgVXNlclByb21wdFN1Ym1pdCBob29rOiBjb21wbGV0ZSBwcmV2aW91cyB0cmFjZSBhbmQgc3RhcnQgYSBuZXcgb25lLgoKICAgIEZpcmVzIGJlZm9yZSBDbGF1ZGUgcHJvY2Vzc2VzIHRoZSBwcm9tcHQsIGdpdmluZyBhbiBhY2N1cmF0ZSB0dXJuX3N0YXJ0X25zCiAgICBhbmQgdGhlIGV4YWN0IHByb21wdCB0ZXh0IHdpdGhvdXQgdHJhbnNjcmlwdCBwYXJzaW5nLgogICAgIiIiCiAgICBzZXNzaW9uX2lkID0gZGF0YS5nZXQoInNlc3Npb25faWQiLCAidW5rbm93biIpCiAgICBwcm9tcHQgPSBkYXRhLmdldCgicHJvbXB0IiwgIiIpCiAgICBub3dfbnMgPSB0aW1lLnRpbWVfbnMoKQoKICAgIHN0YXRlID0gX2xvYWRfc3RhdGUoc2Vzc2lvbl9pZCkKCiAgICAjIEluaXRpYWxpemUgc2Vzc2lvbiBvbiBmaXJzdCBldmVudAogICAgaWYgbm90IHN0YXRlLmdldCgic2Vzc2lvbl9pZCIpOgogICAgICAgIHN0YXRlWyJzZXNzaW9uX2lkIl0gPSBzZXNzaW9uX2lkCiAgICAgICAgc3RhdGVbInNlc3Npb25fc3RhcnRfbnMiXSA9IG5vd19ucwogICAgICAgIHN0YXRlWyJ1c2VybmFtZSJdID0gb3MuZW52aXJvbi5nZXQoCiAgICAgICAgICAgICJVU0VSIiwKICAgICAgICAgICAgb3MuZW52aXJvbi5nZXQoIlVTRVJOQU1FIiwgInVua25vd24iKSwKICAgICAgICApCiAgICAgICAgc3RhdGVbImh1bWFuX21zZ19jb3VudCJdID0gMAogICAgICAgIHN0YXRlWyJ0dXJuX251bWJlciJdID0gMAoKICAgICAgICAjIElmIGEgcGFyZW50IHNlc3Npb24gcHJlLXJlZ2lzdGVyZWQgYW4gQWdlbnQgaW52b2NhdGlvbiwgaW5oZXJpdCBpdHMKICAgICAgICAjIHRyYWNlIGNvbnRleHQgc28gdGhpcyBzdWJhZ2VudCdzIHNwYW5zIGFyZSBuZXN0ZWQgaW5zaWRlIHRoZSBwYXJlbnQgdHJhY2UuCiAgICAgICAgIyBQYXNzIHRoZSBwcm9tcHQgc28gcGFyYWxsZWwgYWdlbnRzIGNhbiBlYWNoIGNsYWltIHRoZWlyIG93biBjb250ZXh0LgogICAgICAgIHBhcmVudF9jdHggPSBfY2xhaW1fcGVuZGluZ19hZ2VudF9jb250ZXh0KHByb21wdD1wcm9tcHQpCiAgICAgICAgaWYgcGFyZW50X2N0eDoKICAgICAgICAgICAgc3RhdGVbImluaGVyaXRlZF90cmFjZV9pZCJdID0gcGFyZW50X2N0eFsicGFyZW50X3RyYWNlX2lkIl0KICAgICAgICAgICAgc3RhdGVbInBhcmVudF9hZ2VudF9zcGFuX2lkIl0gPSBwYXJlbnRfY3R4WyJwYXJlbnRfc3Bhbl9pZCJdCgogICAgIyBDb21wbGV0ZSB0aGUgcHJldmlvdXMgdHVybidzIHRyYWNlIGlmIG9uZSBpcyBpbiBwcm9ncmVzcy4KICAgICMgVXNlIHRoZSBjYWNoZWQgdHJhbnNjcmlwdCBwYXRoIGZyb20gc3RhdGUgc28gdGhhdCBhbnkgbWlkLXNlc3Npb24KICAgICMgcHJvamVjdC1kaXIgY2hhbmdlcyAoZS5nLiBnaCBwciBjcmVhdGUgd3JpdGluZyB0byBhIHdvcmt0cmVlIGRpcikgZG8KICAgICMgbm90IHNpbGVudGx5IHJlZGlyZWN0IHVzIHRvIGEgc2hhZG93IHRyYW5zY3JpcHQgZmlsZS4KICAgIGlmIHN0YXRlLmdldCgiY3VycmVudF90cmFjZSIpOgogICAgICAgIHRyYW5zY3JpcHRfcGF0aCA9IF9nZXRfY2FjaGVkX3RyYW5zY3JpcHRfcGF0aChkYXRhLCBzdGF0ZSwgc2Vzc2lvbl9pZCkKICAgICAgICBfZW1pdF9wZW5kaW5nX2xsbV9zcGFucyhzdGF0ZSwgdHJhbnNjcmlwdF9wYXRoLCBjb25maWcpCiAgICAgICAgX2NvbXBsZXRlX3R1cm4oc3RhdGUsIGNvbmZpZywgdHJhbnNjcmlwdF9wYXRoLCBub3dfbnMpCiAgICAgICAgIyBDbGVhciB0aGUgY2FjaGVkIHBhdGggc28gdGhlIG5ldyB0dXJuIHJlc29sdmVzIGl0IGZyZXNoIGJlbG93CiAgICAgICAgc3RhdGUucG9wKCJ0cmFuc2NyaXB0X3BhdGgiLCBOb25lKQoKICAgICMgQ291bnQgaHVtYW4gbWVzc2FnZXMgZm9yIExMTSBzcGFuIGV4dHJhY3Rpb24uCiAgICAjIFVzZXJQcm9tcHRTdWJtaXQgZmlyZXMgYmVmb3JlIENsYXVkZSBwcm9jZXNzZXMgdGhlIHByb21wdCwgc28gdGhlIG5ldwogICAgIyBtZXNzYWdlIGlzIG5vdCB5ZXQgaW4gdGhlIHRyYW5zY3JpcHQg4oCUIGh1bWFuX2NvdW50X2F0X3N0YXJ0IGVxdWFscyB0aGUKICAgICMgY3VycmVudCBjb3VudCAodGhlIG5ldyBwcm9tcHQgd2lsbCBiZWNvbWUgY291bnQrMSBpbiB0aGUgdHJhbnNjcmlwdCkuCiAgICAjIFJlc29sdmUgYW5kIGltbWVkaWF0ZWx5IGNhY2hlIHRoZSB0cmFuc2NyaXB0IHBhdGggZm9yIHRoaXMgbmV3IHR1cm4uCiAgICB0cmFuc2NyaXB0X3BhdGggPSBfZ2V0X2NhY2hlZF90cmFuc2NyaXB0X3BhdGgoZGF0YSwgc3RhdGUsIHNlc3Npb25faWQpCiAgICBjdXJyZW50X2h1bWFuX2NvdW50ID0gKAogICAgICAgIF9jb3VudF9odW1hbl9tZXNzYWdlcyh0cmFuc2NyaXB0X3BhdGgpIGlmIHRyYW5zY3JpcHRfcGF0aCBlbHNlIDAKICAgICkKCiAgICB0dXJuX251bWJlciA9IHN0YXRlLmdldCgidHVybl9udW1iZXIiLCAwKSArIDEKICAgIHN0YXRlWyJ0dXJuX251bWJlciJdID0gdHVybl9udW1iZXIKICAgIHN0YXRlWyJodW1hbl9tc2dfY291bnQiXSA9IGN1cnJlbnRfaHVtYW5fY291bnQKCiAgICAjIFVzZSB0aGUgaW5oZXJpdGVkIHRyYWNlIElEIGlmIHRoaXMgc2Vzc2lvbiB3YXMgc3Bhd25lZCBhcyBhIHN1YmFnZW50LCBzbwogICAgIyBhbGwgc3BhbnMgbGFuZCBpbiB0aGUgcGFyZW50J3MgdHJhY2UuICBBbHNvIGNvbnN1bWUgdGhlIHBhcmVudCBhZ2VudCBzcGFuCiAgICAjIElEICh1c2VkIG9ubHkgZm9yIHRoZSBmaXJzdCBDSEFJTiBzcGFuIHNvIGl0IGJlY29tZXMgYSBjaGlsZCBvZiB0aGUgQWdlbnQKICAgICMgdG9vbCBzcGFuIGluIHRoZSBwYXJlbnQgdHJhY2UpLgogICAgdHJhY2VfaWQgPSBzdGF0ZS5wb3AoImluaGVyaXRlZF90cmFjZV9pZCIsIE5vbmUpIG9yIF9uZXdfdHJhY2VfaWQoKQogICAgcGFyZW50X2FnZW50X3NwYW5faWQgPSBzdGF0ZS5wb3AoInBhcmVudF9hZ2VudF9zcGFuX2lkIiwgTm9uZSkKCiAgICBzdGF0ZVsiY3VycmVudF90cmFjZSJdID0gewogICAgICAgICJ0cmFjZV9pZCI6IHRyYWNlX2lkLAogICAgICAgICJyb290X3NwYW5faWQiOiBfbmV3X3NwYW5faWQoKSwKICAgICAgICAidHVybl9zdGFydF9ucyI6IG5vd19ucywKICAgICAgICAidHVybl9udW1iZXIiOiB0dXJuX251bWJlciwKICAgICAgICAiaHVtYW5fY291bnRfYXRfc3RhcnQiOiBjdXJyZW50X2h1bWFuX2NvdW50LAogICAgICAgICJwcm9tcHRfcHJldmlldyI6IF90cnVuY2F0ZShwcm9tcHQpIGlmIHByb21wdCBlbHNlICIiLAogICAgICAgICMgTm9uLU5vbmUgb25seSBmb3IgdGhlIGZpcnN0IHR1cm4gb2YgYSBzdWJhZ2VudDsgY2xlYXJlZCBhZnRlciBfY29tcGxldGVfdHVybgogICAgICAgICJwYXJlbnRfYWdlbnRfc3Bhbl9pZCI6IHBhcmVudF9hZ2VudF9zcGFuX2lkLAogICAgfQoKICAgIF9zYXZlX3N0YXRlKHNlc3Npb25faWQsIHN0YXRlKQoKCmRlZiBoYW5kbGVfcHJlX3Rvb2woZGF0YTogZGljdCwgY29uZmlnOiBkaWN0KSAtPiBOb25lOgogICAgc2Vzc2lvbl9pZCA9IGRhdGEuZ2V0KCJzZXNzaW9uX2lkIiwgInVua25vd24iKQogICAgdG9vbF9uYW1lID0gZGF0YS5nZXQoInRvb2xfbmFtZSIsICIiKQogICAgdG9vbF9pbnB1dCA9IGRhdGEuZ2V0KCJ0b29sX2lucHV0Iiwge30pCiAgICBub3dfbnMgPSB0aW1lLnRpbWVfbnMoKQoKICAgIHN0YXRlID0gX2xvYWRfc3RhdGUoc2Vzc2lvbl9pZCkKCiAgICAjIEluaXRpYWxpemUgc2Vzc2lvbiBvbiBmaXJzdCBjYWxsCiAgICBpZiBub3Qgc3RhdGUuZ2V0KCJzZXNzaW9uX2lkIik6CiAgICAgICAgc3RhdGVbInNlc3Npb25faWQiXSA9IHNlc3Npb25faWQKICAgICAgICBzdGF0ZVsic2Vzc2lvbl9zdGFydF9ucyJdID0gbm93X25zCiAgICAgICAgc3RhdGVbInVzZXJuYW1lIl0gPSBvcy5lbnZpcm9uLmdldCgKICAgICAgICAgICAgIlVTRVIiLAogICAgICAgICAgICBvcy5lbnZpcm9uLmdldCgiVVNFUk5BTUUiLCAidW5rbm93biIpLAogICAgICAgICkKICAgICAgICBzdGF0ZVsiaHVtYW5fbXNnX2NvdW50Il0gPSAwCiAgICAgICAgc3RhdGVbInR1cm5fbnVtYmVyIl0gPSAwCgogICAgIyBEZXRlY3QgY29udGV4dCBjb250aW51YXRpb246IENsYXVkZSBDb2RlIGNvbXByZXNzZXMgY29udGV4dCBhbmQgY29udGludWVzCiAgICAjIHdpdGhvdXQgZmlyaW5nIFVzZXJQcm9tcHRTdWJtaXQgZm9yIHRoZSByZXN1bXB0aW9uIG1lc3NhZ2UuIFRoZSBzeW1wdG9tIGlzCiAgICAjIGN1cnJlbnRfdHJhY2Ugc3RpbGwgc2V0IGZyb20gdGhlIHByZXZpb3VzIHR1cm4sIGJ1dCB0aGUgdHJhbnNjcmlwdCBoYXMgZ3Jvd24KICAgICMgYnkgbW9yZSB0aGFuIG9uZSBodW1hbiBtZXNzYWdlIHNpbmNlIHRoYXQgdHJhY2Ugc3RhcnRlZC4KICAgIGlmIHN0YXRlLmdldCgiY3VycmVudF90cmFjZSIpOgogICAgICAgIGN0ID0gc3RhdGVbImN1cnJlbnRfdHJhY2UiXQogICAgICAgIF90cCA9IF9nZXRfY2FjaGVkX3RyYW5zY3JpcHRfcGF0aChkYXRhLCBzdGF0ZSwgc2Vzc2lvbl9pZCkKICAgICAgICBpZiBfdHA6CiAgICAgICAgICAgIF9jdXJfaHVtYW4gPSBfY291bnRfaHVtYW5fbWVzc2FnZXMoX3RwKQogICAgICAgICAgICBfc3RhcnQgPSBjdC5nZXQoImh1bWFuX2NvdW50X2F0X3N0YXJ0IiwgMCkKICAgICAgICAgICAgaWYgX2N1cl9odW1hbiA+IF9zdGFydCArIDE6CiAgICAgICAgICAgICAgICAjIEEgbmV3IHR1cm4gc3RhcnRlZCB3aXRob3V0IFVzZXJQcm9tcHRTdWJtaXQuIENvbXBsZXRlIHRoZSBvbGQKICAgICAgICAgICAgICAgICMgdHJhY2UgYW5kIGZhbGwgdGhyb3VnaCB0byB0aGUgZmFsbGJhY2sgdGhhdCBjcmVhdGVzIGEgbmV3IG9uZS4KICAgICAgICAgICAgICAgIF9lbWl0X3BlbmRpbmdfbGxtX3NwYW5zKHN0YXRlLCBfdHAsIGNvbmZpZykKICAgICAgICAgICAgICAgIF9jb21wbGV0ZV90dXJuKHN0YXRlLCBjb25maWcsIF90cCwgbm93X25zKQogICAgICAgICAgICAgICAgc3RhdGUucG9wKCJjdXJyZW50X3RyYWNlIiwgTm9uZSkKICAgICAgICAgICAgICAgIHN0YXRlLnBvcCgicGVuZGluZ190b29scyIsIE5vbmUpCgogICAgIyBGYWxsYmFjazogY3JlYXRlIGEgdHJhY2UgaWYgVXNlclByb21wdFN1Ym1pdCBoYXMgbm90IHNldCBvbmUgeWV0LgogICAgIyBUaGlzIGhhbmRsZXMgY2FzZXMgd2hlcmUgdGhlIGhvb2sgaXNuJ3QgcmVnaXN0ZXJlZCBvciBmaXJlcyBiZWZvcmUgdGhlCiAgICAjIFVzZXJQcm9tcHRTdWJtaXQgZXZlbnQgaXMgYXZhaWxhYmxlLgogICAgaWYgbm90IHN0YXRlLmdldCgiY3VycmVudF90cmFjZSIpOgogICAgICAgIHRyYW5zY3JpcHRfcGF0aCA9IF9nZXRfY2FjaGVkX3RyYW5zY3JpcHRfcGF0aChkYXRhLCBzdGF0ZSwgc2Vzc2lvbl9pZCkKICAgICAgICBjdXJyZW50X2h1bWFuX2NvdW50ID0gKAogICAgICAgICAgICBfY291bnRfaHVtYW5fbWVzc2FnZXModHJhbnNjcmlwdF9wYXRoKSBpZiB0cmFuc2NyaXB0X3BhdGggZWxzZSAwCiAgICAgICAgKQogICAgICAgIHByb21wdF9wcmV2aWV3ID0gKAogICAgICAgICAgICBfZ2V0X2xhdGVzdF9odW1hbl9tZXNzYWdlKHRyYW5zY3JpcHRfcGF0aCkgaWYgdHJhbnNjcmlwdF9wYXRoIGVsc2UgIiIKICAgICAgICApCgogICAgICAgIHR1cm5fbnVtYmVyID0gc3RhdGUuZ2V0KCJ0dXJuX251bWJlciIsIDApICsgMQogICAgICAgIHN0YXRlWyJ0dXJuX251bWJlciJdID0gdHVybl9udW1iZXIKICAgICAgICBzdGF0ZVsiaHVtYW5fbXNnX2NvdW50Il0gPSBjdXJyZW50X2h1bWFuX2NvdW50CiAgICAgICAgc3RhdGVbImN1cnJlbnRfdHJhY2UiXSA9IHsKICAgICAgICAgICAgInRyYWNlX2lkIjogX25ld190cmFjZV9pZCgpLAogICAgICAgICAgICAicm9vdF9zcGFuX2lkIjogX25ld19zcGFuX2lkKCksCiAgICAgICAgICAgICJ0dXJuX3N0YXJ0X25zIjogbm93X25zLAogICAgICAgICAgICAidHVybl9udW1iZXIiOiB0dXJuX251bWJlciwKICAgICAgICAgICAgIyBodW1hbl9jb3VudF9hdF9zdGFydCBpcyBvbmUgbGVzcyB0aGFuIHRoZSBjdXJyZW50IGNvdW50IHNvIHRoYXQKICAgICAgICAgICAgIyBfZXh0cmFjdF9sbG1fc3BhbnNfZm9yX3R1cm4gZmluZHMgZW50cmllcyB3aGVyZSBjb3VudCA+IHRoaXMgdmFsdWUsCiAgICAgICAgICAgICMgaS5lLiBlbnRyaWVzIGJlbG9uZ2luZyB0byB0aGUgTkVXIHR1cm4gd2hvc2UgcHJvbXB0IGlzIHRoZQogICAgICAgICAgICAjIGN1cnJlbnRfaHVtYW5fY291bnQtdGggaHVtYW4gbWVzc2FnZS4KICAgICAgICAgICAgImh1bWFuX2NvdW50X2F0X3N0YXJ0IjogbWF4KDAsIGN1cnJlbnRfaHVtYW5fY291bnQgLSAxKSwKICAgICAgICAgICAgInByb21wdF9wcmV2aWV3IjogX3RydW5jYXRlKHByb21wdF9wcmV2aWV3KSBpZiBwcm9tcHRfcHJldmlldyBlbHNlICIiLAogICAgICAgIH0KCiAgICBwZW5kaW5nX2VudHJ5OiBkaWN0W3N0ciwgQW55XSA9IHsKICAgICAgICAidG9vbF9uYW1lIjogdG9vbF9uYW1lLAogICAgICAgICJ0b29sX2lucHV0IjogdG9vbF9pbnB1dCwKICAgICAgICAic3RhcnRfbnMiOiBub3dfbnMsCiAgICB9CgogICAgIyBGb3IgQWdlbnQgdG9vbCBjYWxscywgcHJlLWFsbG9jYXRlIHRoZSBzcGFuIElEIGFuZCB3cml0ZSBhIHNpZGUtY2hhbm5lbAogICAgIyBmaWxlIHNvIHRoZSBzcGF3bmVkIHN1YmFnZW50IGNhbiBpbmhlcml0IHRoaXMgdHJhY2UuCiAgICBjdXJyZW50X3RyYWNlID0gc3RhdGUuZ2V0KCJjdXJyZW50X3RyYWNlIikKICAgIGlmIHRvb2xfbmFtZSA9PSAiQWdlbnQiIGFuZCBjdXJyZW50X3RyYWNlOgogICAgICAgIGFnZW50X3NwYW5faWQgPSBfbmV3X3NwYW5faWQoKQogICAgICAgIHBlbmRpbmdfZW50cnlbInByZV9hbGxvY2F0ZWRfc3Bhbl9pZCJdID0gYWdlbnRfc3Bhbl9pZAogICAgICAgIGFnZW50X3Byb21wdCA9ICgKICAgICAgICAgICAgdG9vbF9pbnB1dC5nZXQoInByb21wdCIsICIiKSBpZiBpc2luc3RhbmNlKHRvb2xfaW5wdXQsIGRpY3QpIGVsc2UgIiIKICAgICAgICApCiAgICAgICAgX3dyaXRlX3BlbmRpbmdfYWdlbnRfY29udGV4dCgKICAgICAgICAgICAgcGFyZW50X3Nlc3Npb25faWQ9c2Vzc2lvbl9pZCwKICAgICAgICAgICAgcGFyZW50X3RyYWNlX2lkPWN1cnJlbnRfdHJhY2VbInRyYWNlX2lkIl0sCiAgICAgICAgICAgIHBhcmVudF9zcGFuX2lkPWFnZW50X3NwYW5faWQsCiAgICAgICAgICAgIGFnZW50X3Byb21wdD1hZ2VudF9wcm9tcHQsCiAgICAgICAgKQoKICAgIHN0YXRlLnNldGRlZmF1bHQoInBlbmRpbmdfdG9vbHMiLCB7fSlbdG9vbF9uYW1lXSA9IHBlbmRpbmdfZW50cnkKICAgIF9zYXZlX3N0YXRlKHNlc3Npb25faWQsIHN0YXRlKQoKCmRlZiBfZ2V0X2xhdGVzdF9odW1hbl9tZXNzYWdlKHRyYW5zY3JpcHRfcGF0aDogc3RyKSAtPiBzdHI6CiAgICAiIiJSZXR1cm4gdGhlIHRleHQgb2YgdGhlIG1vc3QgcmVjZW50IGh1bWFuIG1lc3NhZ2UgaW4gdGhlIHRyYW5zY3JpcHQuIiIiCiAgICB0cnk6CiAgICAgICAgbGFzdCA9ICIiCiAgICAgICAgZm9yIGxpbmUgaW4gUGF0aCh0cmFuc2NyaXB0X3BhdGgpLnJlYWRfdGV4dCgpLnNwbGl0bGluZXMoKToKICAgICAgICAgICAgaWYgbm90IGxpbmUuc3RyaXAoKToKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGVudHJ5ID0ganNvbi5sb2FkcyhsaW5lKQogICAgICAgICAgICAgICAgaWYgX2lzX2h1bWFuX21lc3NhZ2UoZW50cnkpOgogICAgICAgICAgICAgICAgICAgIGNvbnRlbnQgPSBlbnRyeS5nZXQoIm1lc3NhZ2UiLCB7fSkuZ2V0KCJjb250ZW50IiwgIiIpCiAgICAgICAgICAgICAgICAgICAgaWYgaXNpbnN0YW5jZShjb250ZW50LCBzdHIpOgogICAgICAgICAgICAgICAgICAgICAgICBsYXN0ID0gY29udGVudAogICAgICAgICAgICBleGNlcHQganNvbi5KU09ORGVjb2RlRXJyb3I6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgcmV0dXJuIGxhc3QKICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgcmV0dXJuICIiCgoKZGVmIGhhbmRsZV9wb3N0X3Rvb2woZGF0YTogZGljdCwgY29uZmlnOiBkaWN0KSAtPiBOb25lOgogICAgc2Vzc2lvbl9pZCA9IGRhdGEuZ2V0KCJzZXNzaW9uX2lkIiwgInVua25vd24iKQogICAgZW5kX25zID0gdGltZS50aW1lX25zKCkKCiAgICAjIFJlYWQgc3RhdGUgb25jZSB0byBidWlsZCB0aGUgdG9vbCBzcGFuIChubyBtdXRhdGlvbiBuZWVkZWQpLgogICAgc3RhdGUgPSBfbG9hZF9zdGF0ZShzZXNzaW9uX2lkKQogICAgaWYgbm90IHN0YXRlOgogICAgICAgIGxvZy53YXJuaW5nKCJObyBzdGF0ZSBmb3VuZCBmb3Igc2Vzc2lvbiAlcyBpbiBwb3N0X3Rvb2wiLCBzZXNzaW9uX2lkKQogICAgICAgIHJldHVybgoKICAgIGN1cnJlbnRfdHJhY2UgPSBzdGF0ZS5nZXQoImN1cnJlbnRfdHJhY2UiKQogICAgaWYgbm90IGN1cnJlbnRfdHJhY2U6CiAgICAgICAgbG9nLmRlYnVnKCJObyBjdXJyZW50IHRyYWNlIGZvciBzZXNzaW9uICVzIGluIHBvc3RfdG9vbCIsIHNlc3Npb25faWQpCiAgICAgICAgcmV0dXJuCgogICAgdG9vbF9uYW1lID0gZGF0YS5nZXQoInRvb2xfbmFtZSIsICJ1bmtub3duIikKICAgIGN1cnJlbnRfdG9vbCA9IHN0YXRlLmdldCgicGVuZGluZ190b29scyIsIHt9KS5nZXQodG9vbF9uYW1lLCB7fSkKICAgIHRvb2xfaW5wdXQgPSBkYXRhLmdldCgidG9vbF9pbnB1dCIpIG9yIGN1cnJlbnRfdG9vbC5nZXQoInRvb2xfaW5wdXQiLCB7fSkKICAgIHRvb2xfcmVzcG9uc2UgPSBkYXRhLmdldCgidG9vbF9yZXNwb25zZSIsIHt9KQogICAgc3RhcnRfbnMgPSBjdXJyZW50X3Rvb2wuZ2V0KCJzdGFydF9ucyIsIGVuZF9ucyAtIDFfMDAwXzAwMCkKICAgICMgRm9yIEFnZW50IHRvb2wgY2FsbHMgdGhlIHNwYW4gSUQgd2FzIHByZS1hbGxvY2F0ZWQgYXQgUHJlVG9vbFVzZSB0aW1lIHNvCiAgICAjIHRoZSBzdWJhZ2VudCBjb3VsZCByZWZlcmVuY2UgaXQgYXMgaXRzIHBhcmVudCBzcGFuLgogICAgcHJlX2FsbG9jYXRlZF9zcGFuX2lkID0gY3VycmVudF90b29sLmdldCgicHJlX2FsbG9jYXRlZF9zcGFuX2lkIikKCiAgICBzcGFuX3JlY29yZCA9IF9idWlsZF90b29sX3NwYW5fcmVjb3JkKAogICAgICAgIHRvb2xfbmFtZT10b29sX25hbWUsCiAgICAgICAgdG9vbF9pbnB1dD10b29sX2lucHV0LAogICAgICAgIHRvb2xfcmVzcG9uc2U9dG9vbF9yZXNwb25zZSwKICAgICAgICBzdGFydF9ucz1zdGFydF9ucywKICAgICAgICBlbmRfbnM9ZW5kX25zLAogICAgICAgIHRyYWNlX2lkPWN1cnJlbnRfdHJhY2VbInRyYWNlX2lkIl0sCiAgICAgICAgcm9vdF9zcGFuX2lkPWN1cnJlbnRfdHJhY2VbInJvb3Rfc3Bhbl9pZCJdLAogICAgICAgIHNwYW5faWQ9cHJlX2FsbG9jYXRlZF9zcGFuX2lkLAogICAgKQoKICAgICMgRXhwb3J0IHRoZSB0b29sIHNwYW4gYmVmb3JlIGFjcXVpcmluZyB0aGUgbG9jayDigJQgdGhpcyBpcyB0aGUgc2xvdyBuZXR3b3JrCiAgICAjIEkvTyBhbmQgZG9lcyBub3QgbXV0YXRlIHN0YXRlLCBzbyBpdCBpcyBzYWZlIHRvIHJ1biBvdXRzaWRlIHRoZSBsb2NrLgogICAgX2J1aWxkX2FuZF9leHBvcnRfc3BhbnMoCiAgICAgICAgY29uZmlnPWNvbmZpZywKICAgICAgICBzZXNzaW9uX2lkPXNlc3Npb25faWQsCiAgICAgICAgdXNlcm5hbWU9c3RhdGUuZ2V0KCJ1c2VybmFtZSIsICJ1bmtub3duIiksCiAgICAgICAgc3Bhbl9yZWNvcmRzPVtzcGFuX3JlY29yZF0sCiAgICApCgogICAgIyBBY3F1aXJlIGFuIGV4Y2x1c2l2ZSBwZXItc2Vzc2lvbiBsb2NrIGJlZm9yZSBlbWl0dGluZyBMTE0gc3BhbnMuCiAgICAjIFBhcmFsbGVsIFBvc3RUb29sVXNlIHByb2Nlc3NlcyByYWNlIG9uIGVtaXR0ZWRfbGxtX3NwYW5fY291bnQ7IHRoZSBsb2NrCiAgICAjIGVuc3VyZXMgZWFjaCBwcm9jZXNzIHJlLXJlYWRzIHRoZSBsYXRlc3QgY291bnQgYW5kIG5ldmVyIGRvdWJsZS1lbWl0cy4KICAgICMgUmVzb2x2ZSB0aGUgdHJhbnNjcmlwdCBwYXRoICppbnNpZGUqIHRoZSBsb2NrIHNvIHdlIGFsd2F5cyB1c2UgdGhlIHBhdGgKICAgICMgdGhhdCB3YXMgY2FjaGVkIGluIHN0YXRlIGF0IFVzZXJQcm9tcHRTdWJtaXQgdGltZSDigJQgbm90IHRoZSAocG90ZW50aWFsbHkKICAgICMgc3RhbGUgb3IgcmVkaXJlY3RlZCkgcGF0aCBjYXJyaWVkIGluIHRoZSBjdXJyZW50IGhvb2sgcGF5bG9hZC4KICAgIHdpdGggX3Nlc3Npb25fbG9jayhzZXNzaW9uX2lkKToKICAgICAgICBzdGF0ZSA9IF9sb2FkX3N0YXRlKHNlc3Npb25faWQpCiAgICAgICAgc3RhdGUuZ2V0KCJwZW5kaW5nX3Rvb2xzIiwge30pLnBvcCh0b29sX25hbWUsIE5vbmUpCiAgICAgICAgdHJhbnNjcmlwdF9wYXRoID0gX2dldF9jYWNoZWRfdHJhbnNjcmlwdF9wYXRoKGRhdGEsIHN0YXRlLCBzZXNzaW9uX2lkKQogICAgICAgIF9lbWl0X3BlbmRpbmdfbGxtX3NwYW5zKHN0YXRlLCB0cmFuc2NyaXB0X3BhdGgsIGNvbmZpZykKICAgICAgICBfc2F2ZV9zdGF0ZShzZXNzaW9uX2lkLCBzdGF0ZSkKCgpkZWYgaGFuZGxlX3Bvc3RfdG9vbF9mYWlsdXJlKGRhdGE6IGRpY3QsIGNvbmZpZzogZGljdCkgLT4gTm9uZToKICAgICIiIkhhbmRsZSBQb3N0VG9vbFVzZUZhaWx1cmU6IGVtaXQgYW4gZXJyb3IgVE9PTC9SRVRSSUVWRVIvQUdFTlQgc3Bhbi4iIiIKICAgIHNlc3Npb25faWQgPSBkYXRhLmdldCgic2Vzc2lvbl9pZCIsICJ1bmtub3duIikKICAgIGVuZF9ucyA9IHRpbWUudGltZV9ucygpCgogICAgc3RhdGUgPSBfbG9hZF9zdGF0ZShzZXNzaW9uX2lkKQogICAgaWYgbm90IHN0YXRlOgogICAgICAgIGxvZy53YXJuaW5nKCJObyBzdGF0ZSBmb3VuZCBmb3Igc2Vzc2lvbiAlcyBpbiBwb3N0X3Rvb2xfZmFpbHVyZSIsIHNlc3Npb25faWQpCiAgICAgICAgcmV0dXJuCgogICAgY3VycmVudF90cmFjZSA9IHN0YXRlLmdldCgiY3VycmVudF90cmFjZSIpCiAgICBpZiBub3QgY3VycmVudF90cmFjZToKICAgICAgICBsb2cuZGVidWcoIk5vIGN1cnJlbnQgdHJhY2UgZm9yIHNlc3Npb24gJXMgaW4gcG9zdF90b29sX2ZhaWx1cmUiLCBzZXNzaW9uX2lkKQogICAgICAgIHJldHVybgoKICAgIHRvb2xfbmFtZSA9IGRhdGEuZ2V0KCJ0b29sX25hbWUiLCAidW5rbm93biIpCiAgICBjdXJyZW50X3Rvb2wgPSBzdGF0ZS5nZXQoInBlbmRpbmdfdG9vbHMiLCB7fSkuZ2V0KHRvb2xfbmFtZSwge30pCiAgICB0b29sX2lucHV0ID0gZGF0YS5nZXQoInRvb2xfaW5wdXQiKSBvciBjdXJyZW50X3Rvb2wuZ2V0KCJ0b29sX2lucHV0Iiwge30pCiAgICB0b29sX3Jlc3BvbnNlID0gZGF0YS5nZXQoInRvb2xfcmVzcG9uc2UiLCB7fSkKICAgIHN0YXJ0X25zID0gY3VycmVudF90b29sLmdldCgic3RhcnRfbnMiLCBlbmRfbnMgLSAxXzAwMF8wMDApCgogICAgIyBFeHRyYWN0IGVycm9yIG1lc3NhZ2UgZnJvbSB2YXJpb3VzIHBvc3NpYmxlIGZpZWxkcwogICAgZXJyb3JfbXNnID0gIiIKICAgIGlmIGlzaW5zdGFuY2UodG9vbF9yZXNwb25zZSwgZGljdCk6CiAgICAgICAgZXJyb3JfbXNnID0gdG9vbF9yZXNwb25zZS5nZXQoImVycm9yIiwgdG9vbF9yZXNwb25zZS5nZXQoIm1lc3NhZ2UiLCAiIikpCiAgICBlbGlmIGlzaW5zdGFuY2UodG9vbF9yZXNwb25zZSwgc3RyKToKICAgICAgICBlcnJvcl9tc2cgPSB0b29sX3Jlc3BvbnNlCiAgICBpZiBub3QgZXJyb3JfbXNnOgogICAgICAgIGVycm9yX21zZyA9IGRhdGEuZ2V0KCJlcnJvciIsIGRhdGEuZ2V0KCJlcnJvcl9tZXNzYWdlIiwgIlRvb2wgY2FsbCBmYWlsZWQiKSkKCiAgICBzcGFuX3JlY29yZCA9IF9idWlsZF90b29sX3NwYW5fcmVjb3JkKAogICAgICAgIHRvb2xfbmFtZT10b29sX25hbWUsCiAgICAgICAgdG9vbF9pbnB1dD10b29sX2lucHV0LAogICAgICAgIHRvb2xfcmVzcG9uc2U9dG9vbF9yZXNwb25zZSwKICAgICAgICBzdGFydF9ucz1zdGFydF9ucywKICAgICAgICBlbmRfbnM9ZW5kX25zLAogICAgICAgIHRyYWNlX2lkPWN1cnJlbnRfdHJhY2VbInRyYWNlX2lkIl0sCiAgICAgICAgcm9vdF9zcGFuX2lkPWN1cnJlbnRfdHJhY2VbInJvb3Rfc3Bhbl9pZCJdLAogICAgICAgIGlzX2ZhaWx1cmU9VHJ1ZSwKICAgICAgICBlcnJvcl9tc2c9ZXJyb3JfbXNnLAogICAgKQoKICAgIF9idWlsZF9hbmRfZXhwb3J0X3NwYW5zKAogICAgICAgIGNvbmZpZz1jb25maWcsCiAgICAgICAgc2Vzc2lvbl9pZD1zZXNzaW9uX2lkLAogICAgICAgIHVzZXJuYW1lPXN0YXRlLmdldCgidXNlcm5hbWUiLCAidW5rbm93biIpLAogICAgICAgIHNwYW5fcmVjb3Jkcz1bc3Bhbl9yZWNvcmRdLAogICAgKQoKICAgIHdpdGggX3Nlc3Npb25fbG9jayhzZXNzaW9uX2lkKToKICAgICAgICBzdGF0ZSA9IF9sb2FkX3N0YXRlKHNlc3Npb25faWQpCiAgICAgICAgc3RhdGUuZ2V0KCJwZW5kaW5nX3Rvb2xzIiwge30pLnBvcCh0b29sX25hbWUsIE5vbmUpCiAgICAgICAgdHJhbnNjcmlwdF9wYXRoID0gX2dldF9jYWNoZWRfdHJhbnNjcmlwdF9wYXRoKGRhdGEsIHN0YXRlLCBzZXNzaW9uX2lkKQogICAgICAgIF9lbWl0X3BlbmRpbmdfbGxtX3NwYW5zKHN0YXRlLCB0cmFuc2NyaXB0X3BhdGgsIGNvbmZpZykKICAgICAgICBfc2F2ZV9zdGF0ZShzZXNzaW9uX2lkLCBzdGF0ZSkKCgpkZWYgaGFuZGxlX3N0b3AoZGF0YTogZGljdCwgY29uZmlnOiBkaWN0KSAtPiBOb25lOgogICAgc2Vzc2lvbl9pZCA9IGRhdGEuZ2V0KCJzZXNzaW9uX2lkIiwgInVua25vd24iKQogICAgZW5kX25zID0gdGltZS50aW1lX25zKCkKCiAgICB3aXRoIF9zZXNzaW9uX2xvY2soc2Vzc2lvbl9pZCk6CiAgICAgICAgc3RhdGUgPSBfbG9hZF9zdGF0ZShzZXNzaW9uX2lkKQogICAgICAgIGlmIG5vdCBzdGF0ZS5nZXQoImN1cnJlbnRfdHJhY2UiKToKICAgICAgICAgICAgbG9nLmRlYnVnKCJObyBhY3RpdmUgdHJhY2UgZm9yIHNlc3Npb24gJXMgYXQgc3RvcCIsIHNlc3Npb25faWQpCiAgICAgICAgICAgIHJldHVybgoKICAgICAgICB0cmFuc2NyaXB0X3BhdGggPSBfZ2V0X2NhY2hlZF90cmFuc2NyaXB0X3BhdGgoZGF0YSwgc3RhdGUsIHNlc3Npb25faWQpCgogICAgICAgICMgRGV0ZWN0IGNvbnRleHQgY29udGludWF0aW9uOiBzYW1lIGNoZWNrIGFzIGhhbmRsZV9wcmVfdG9vbC4KICAgICAgICAjIFdoZW4gbm8gdG9vbCBjYWxscyBoYXBwZW4gZHVyaW5nIGEgY29udGludWF0aW9uIHR1cm4sIGhhbmRsZV9wcmVfdG9vbAogICAgICAgICMgbmV2ZXIgZmlyZXMsIHNvIHRoZSBzdGFsZSB0cmFjZSB3b3VsZCBvdGhlcndpc2UgYmUgY29tcGxldGVkIHdpdGggdGhlCiAgICAgICAgIyB3cm9uZyBMTE0gc3BhbnMuICBDb21wbGV0ZSB0aGUgb2xkIHRyYWNlIGhlcmUgYW5kIHN0YXJ0IGEgbmV3IG9uZSBzbwogICAgICAgICMgdGhlIG5vcm1hbCBzdG9wIGxvZ2ljIGJlbG93IHJ1bnMgYWdhaW5zdCB0aGUgY29ycmVjdCB0dXJuLgogICAgICAgIGlmIHRyYW5zY3JpcHRfcGF0aDoKICAgICAgICAgICAgY3QgPSBzdGF0ZVsiY3VycmVudF90cmFjZSJdCiAgICAgICAgICAgIF9jdXJfaHVtYW4gPSBfY291bnRfaHVtYW5fbWVzc2FnZXModHJhbnNjcmlwdF9wYXRoKQogICAgICAgICAgICBfc3RhcnQgPSBjdC5nZXQoImh1bWFuX2NvdW50X2F0X3N0YXJ0IiwgMCkKICAgICAgICAgICAgaWYgX2N1cl9odW1hbiA+IF9zdGFydCArIDE6CiAgICAgICAgICAgICAgICBfZW1pdF9wZW5kaW5nX2xsbV9zcGFucyhzdGF0ZSwgdHJhbnNjcmlwdF9wYXRoLCBjb25maWcpCiAgICAgICAgICAgICAgICBfY29tcGxldGVfdHVybihzdGF0ZSwgY29uZmlnLCB0cmFuc2NyaXB0X3BhdGgsIGVuZF9ucykKICAgICAgICAgICAgICAgIHN0YXRlLnBvcCgiY3VycmVudF90cmFjZSIsIE5vbmUpCiAgICAgICAgICAgICAgICBzdGF0ZS5wb3AoInBlbmRpbmdfdG9vbHMiLCBOb25lKQoKICAgICAgICAgICAgICAgIGN1cnJlbnRfaHVtYW5fY291bnQgPSBfY3VyX2h1bWFuCiAgICAgICAgICAgICAgICBwcm9tcHRfcHJldmlldyA9IF9nZXRfbGF0ZXN0X2h1bWFuX21lc3NhZ2UodHJhbnNjcmlwdF9wYXRoKQogICAgICAgICAgICAgICAgdHVybl9udW1iZXIgPSBzdGF0ZS5nZXQoInR1cm5fbnVtYmVyIiwgMCkgKyAxCiAgICAgICAgICAgICAgICBzdGF0ZVsidHVybl9udW1iZXIiXSA9IHR1cm5fbnVtYmVyCiAgICAgICAgICAgICAgICBzdGF0ZVsiaHVtYW5fbXNnX2NvdW50Il0gPSBjdXJyZW50X2h1bWFuX2NvdW50CiAgICAgICAgICAgICAgICBzdGF0ZVsiY3VycmVudF90cmFjZSJdID0gewogICAgICAgICAgICAgICAgICAgICJ0cmFjZV9pZCI6IF9uZXdfdHJhY2VfaWQoKSwKICAgICAgICAgICAgICAgICAgICAicm9vdF9zcGFuX2lkIjogX25ld19zcGFuX2lkKCksCiAgICAgICAgICAgICAgICAgICAgInR1cm5fc3RhcnRfbnMiOiBlbmRfbnMsCiAgICAgICAgICAgICAgICAgICAgInR1cm5fbnVtYmVyIjogdHVybl9udW1iZXIsCiAgICAgICAgICAgICAgICAgICAgImh1bWFuX2NvdW50X2F0X3N0YXJ0IjogbWF4KDAsIGN1cnJlbnRfaHVtYW5fY291bnQgLSAxKSwKICAgICAgICAgICAgICAgICAgICAicHJvbXB0X3ByZXZpZXciOiBfdHJ1bmNhdGUocHJvbXB0X3ByZXZpZXcpIGlmIHByb21wdF9wcmV2aWV3IGVsc2UgIiIsCiAgICAgICAgICAgICAgICB9CgogICAgICAgICMgRW1pdCB0aGUgZmluYWwgTExNIHNwYW4ocykg4oCUIHRoZSBsYXN0IHJlc3BvbnNlIGhhcyBubyB0b29sIGNhbGwgYWZ0ZXIgaXQsCiAgICAgICAgIyBzbyBoYW5kbGVfcG9zdF90b29sIG5ldmVyIGdvdCB0aGUgY2hhbmNlIHRvIGVtaXQgaXQuCiAgICAgICAgX2VtaXRfcGVuZGluZ19sbG1fc3BhbnMoc3RhdGUsIHRyYW5zY3JpcHRfcGF0aCwgY29uZmlnKQoKICAgICAgICBfY29tcGxldGVfdHVybihzdGF0ZSwgY29uZmlnLCB0cmFuc2NyaXB0X3BhdGgsIGVuZF9ucykKCiAgICAgICAgX2RlbGV0ZV9zdGF0ZShzZXNzaW9uX2lkKQoKICAgIF9jbGVhbnVwX3N0YWxlX3N0YXRlcygpCgoKIyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KIyBNYWluCiMgLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgoKZGVmIG1haW4oKSAtPiBOb25lOgogICAgaWYgbGVuKHN5cy5hcmd2KSA8IDI6CiAgICAgICAgcHJpbnQoCiAgICAgICAgICAgICJVc2FnZTogY2xhdWRlX2NvZGVfdHJhY2VyLnB5IDxwcmVfdG9vbHxwb3N0X3Rvb2x8cG9zdF90b29sX2ZhaWx1cmV8dXNlcl9wcm9tcHRfc3VibWl0fHN0b3A+IiwKICAgICAgICAgICAgZmlsZT1zeXMuc3RkZXJyLAogICAgICAgICkKICAgICAgICBzeXMuZXhpdCgwKQoKICAgIGV2ZW50ID0gc3lzLmFyZ3ZbMV0KCiAgICB0cnk6CiAgICAgICAgcmF3ID0gc3lzLnN0ZGluLnJlYWQoKQogICAgICAgIGRhdGEgPSBqc29uLmxvYWRzKHJhdykgaWYgcmF3LnN0cmlwKCkgZWxzZSB7fQogICAgZXhjZXB0IGpzb24uSlNPTkRlY29kZUVycm9yIGFzIGU6CiAgICAgICAgbG9nLndhcm5pbmcoIkZhaWxlZCB0byBwYXJzZSBzdGRpbiBKU09OOiAlcyIsIGUpCiAgICAgICAgZGF0YSA9IHt9CgogICAgY29uZmlnID0gZGlzY292ZXJfY29uZmlnKCkKICAgIGlmIGNvbmZpZyBpcyBOb25lOgogICAgICAgIGxvZy5kZWJ1ZygiQXJ0aHVyIEVuZ2luZSBub3QgY29uZmlndXJlZCwgc2tpcHBpbmcgdHJhY2luZy4iKQogICAgICAgIHN5cy5leGl0KDApCgogICAgdHJ5OgogICAgICAgIGlmIGV2ZW50ID09ICJwcmVfdG9vbCI6CiAgICAgICAgICAgIGhhbmRsZV9wcmVfdG9vbChkYXRhLCBjb25maWcpCiAgICAgICAgZWxpZiBldmVudCA9PSAicG9zdF90b29sIjoKICAgICAgICAgICAgaGFuZGxlX3Bvc3RfdG9vbChkYXRhLCBjb25maWcpCiAgICAgICAgZWxpZiBldmVudCA9PSAicG9zdF90b29sX2ZhaWx1cmUiOgogICAgICAgICAgICBoYW5kbGVfcG9zdF90b29sX2ZhaWx1cmUoZGF0YSwgY29uZmlnKQogICAgICAgIGVsaWYgZXZlbnQgPT0gInVzZXJfcHJvbXB0X3N1Ym1pdCI6CiAgICAgICAgICAgIGhhbmRsZV91c2VyX3Byb21wdF9zdWJtaXQoZGF0YSwgY29uZmlnKQogICAgICAgIGVsaWYgZXZlbnQgPT0gInN0b3AiOgogICAgICAgICAgICBoYW5kbGVfc3RvcChkYXRhLCBjb25maWcpCiAgICAgICAgZWxzZToKICAgICAgICAgICAgbG9nLndhcm5pbmcoIlVua25vd24gZXZlbnQ6ICVzIiwgZXZlbnQpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgbG9nLndhcm5pbmcoIlRyYWNlciBlcnJvciAoJXMpOiAlcyIsIGV2ZW50LCBlLCBleGNfaW5mbz1UcnVlKQoKICAgIHN5cy5leGl0KDApCgoKaWYgX19uYW1lX18gPT0gIl9fbWFpbl9fIjoKICAgIG1haW4oKQo="; diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index 284e708..eeaad50 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -111,7 +111,7 @@ describe("SandboxManager", () => { expect(content).not.toContain("ANTHROPIC_API_KEY"); }); - it("configures stop hook when enabled", async () => { + it("enabling the stop hook runs a node merge script that adds commit-guard", async () => { const manager = new SandboxManager({ kind: "github", token: "ghp_test", @@ -123,17 +123,25 @@ describe("SandboxManager", () => { commitEmail: "bot@blazity.com", jobTimeoutMs: 1_800_000, }); - const sandbox = await manager.provision("feat/test-branch"); + mockRunCommand.mockClear(); + await manager.configureStopHook(sandbox, true); - // Should have called runCommand with the commit-guard script - const calls = mockRunCommand.mock.calls.map((c: any[]) => c[0] === "bash" ? c[1]?.[1] ?? c[1]?.[0] : ""); - const hookCall = calls.find((c: string) => typeof c === "string" && c.includes("commit-guard")); - expect(hookCall).toBeDefined(); + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes("commit-guard.sh") && + c[1][2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); }); - it("clears stop hook when disabled", async () => { + it("disabling the stop hook runs a node merge script with commitGuard=disable", async () => { const manager = new SandboxManager({ kind: "github", token: "ghp_test", @@ -145,29 +153,147 @@ describe("SandboxManager", () => { commitEmail: "bot@blazity.com", jobTimeoutMs: 1_800_000, }); - const sandbox = await manager.provision("feat/test-branch"); mockRunCommand.mockClear(); + await manager.configureStopHook(sandbox, false); - // Should write empty settings - const calls = mockRunCommand.mock.calls; - const clearCall = calls.find((c: any[]) => - c[0] === "bash" && typeof c[1]?.[1] === "string" && c[1][1].includes("'{}' > ~/.claude/settings.json"), + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"commitGuard":"disable"'), ); - expect(clearCall).toBeDefined(); + expect(mergeCall).toBeDefined(); }); it("configureStopHookInSandbox works with any sandbox-like object", async () => { const fakeSandbox = { runCommand: mockRunCommand }; - mockRunCommand.mockClear(); + await configureStopHookInSandbox(fakeSandbox as any, true); - const hookCall = mockRunCommand.mock.calls.find( - (c: any[]) => c[0] === "bash" && typeof c[1]?.[1] === "string" && c[1][1].includes("commit-guard"), + const mergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); + }); + + it("installs Arthur tracer when config.arthur is set", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + arthur: { + apiKey: "test-key", + taskId: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }, + }); + + await manager.provision("feat/test-branch"); + + const pipCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "bash" && + typeof c[1]?.[1] === "string" && + c[1][1].includes("ensurepip") && + c[1][1].includes("python3 -m pip install") && + c[1][1].includes("opentelemetry-sdk") && + c[1][1].includes("opentelemetry-exporter-otlp-proto-http"), + ); + expect(pipCall).toBeDefined(); + + const arthurMergeCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "node" && + Array.isArray(c[1]) && + c[1][0] === "--input-type=module" && + c[1][1] === "-e" && + typeof c[1][2] === "string" && + c[1][2].includes('"arthur":"install"') && + c[1][2].includes("user_prompt_submit") && + c[1][2].includes("pre_tool") && + c[1][2].includes("post_tool") && + c[1][2].includes("post_tool_failure"), + ); + expect(arthurMergeCall).toBeDefined(); + }); + + it("skips Arthur install when config.arthur is undefined", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + }); + + await manager.provision("feat/test-branch"); + + const pipCall = mockRunCommand.mock.calls.find( + (c: any[]) => + c[0] === "bash" && + typeof c[1]?.[1] === "string" && + c[1][1].includes("python3 -m pip install"), ); - expect(hookCall).toBeDefined(); + expect(pipCall).toBeUndefined(); + }); + + it("Arthur install writes arthur_config.json and the tracer script", async () => { + const manager = new SandboxManager({ + kind: "github", + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + anthropicApiKey: "sk-ant-test", + claudeModel: "claude-opus-4-6", + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + jobTimeoutMs: 1_800_000, + arthur: { + apiKey: "test-key", + taskId: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }, + }); + + await manager.provision("feat/test-branch"); + + // Every writeFiles call passes an array of { path, content }. Flatten them. + const written = mockWriteFiles.mock.calls.flatMap(([files]: any[]) => files); + const tracerFile = written.find((f: any) => f.path.endsWith("arthur-tracer.py")); + expect(tracerFile).toBeDefined(); + expect(Buffer.isBuffer(tracerFile.content)).toBe(true); + expect(tracerFile.content.length).toBeGreaterThan(1000); + + const configFile = written.find((f: any) => f.path.endsWith("arthur_config.json")); + expect(configFile).toBeDefined(); + const cfg = JSON.parse(Buffer.from(configFile.content).toString()); + expect(cfg).toEqual({ + api_key: "test-key", + task_id: "00000000-0000-4000-8000-000000000000", + endpoint: "https://example.ngrok.app/api/v1/traces", + }); }); }); diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index e62cfc2..a838844 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,5 +1,6 @@ import type { Sandbox as SandboxType } from "@vercel/sandbox"; import { getSandboxCredentials } from "./credentials.js"; +import { ARTHUR_TRACER_PY_BASE64 } from "./arthur-tracer.js"; /** * Skills installed globally in the sandbox (~/.claude/skills/). @@ -11,6 +12,12 @@ const GLOBAL_SKILLS = [ { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, ] as const; +export interface ArthurConfig { + apiKey: string; + taskId: string; + endpoint: string; +} + export interface SandboxConfig { kind: "github" | "gitlab"; token: string; @@ -24,6 +31,8 @@ export interface SandboxConfig { commitAuthor: string; commitEmail: string; jobTimeoutMs: number; + /** Arthur AI Engine tracing config. If set, the tracer is installed into every provisioned sandbox. */ + arthur?: ArthurConfig; } /** Build clone/push URLs for the configured VCS. Supports github.com and any GitLab host (incl. self-hosted). */ @@ -56,39 +65,96 @@ interface RunnableSandbox { runCommand: SandboxInstance["runCommand"]; } +/** + * Merge-aware writer for ~/.claude/settings.json inside a sandbox. + * + * Accepts a partial "directive" — only the keys provided are mutated; existing + * hook entries (including those owned by other tools, e.g. Arthur's tracer) + * are preserved. The merge itself runs inside the sandbox via `node -e` + * because Node 24 is the sandbox runtime and we can't assume Python is + * available for stop-hook toggling. + */ +async function writeClaudeSettings( + sandbox: RunnableSandbox, + opts: { + commitGuard?: "enable" | "disable"; + arthur?: "install"; + }, +): Promise { + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const home = process.env.HOME; + const settingsPath = path.join(home, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsertHook = (event, matcher, command) => { + const existing = s.hooks[event] || []; + const has = existing.some(e => (e && Array.isArray(e.hooks) ? e.hooks : []).some(h => h && h.command === command)); + if (!has) existing.push({ matcher, hooks: [{ type: 'command', command }] }); + s.hooks[event] = existing; + }; + const removeHook = (event, commandPredicate) => { + const existing = s.hooks[event] || []; + s.hooks[event] = existing + .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !commandPredicate(h.command || '')) })) + .filter(e => (e.hooks || []).length > 0); + }; + + if (opts.commitGuard === 'enable') { + upsertHook('Stop', '', 'bash ~/.claude/commit-guard.sh'); + } else if (opts.commitGuard === 'disable') { + removeHook('Stop', c => c.includes('commit-guard.sh')); + } + + if (opts.arthur === 'install') { + const events = [ + ['UserPromptSubmit', 'user_prompt_submit'], + ['PreToolUse', 'pre_tool'], + ['PostToolUse', 'post_tool'], + ['PostToolUseFailure', 'post_tool_failure'], + ['Stop', 'stop'], + ]; + for (const [event, arg] of events) { + upsertHook(event, '', 'python3 "$HOME/.claude/hooks/claude_code_tracer.py" ' + arg); + } + } + + fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); + `; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); +} + /** * Configures or disables the commit-guard stop hook in a sandbox. * Standalone function so both SandboxManager and workflow steps can call it * without type mismatches between Sandbox.create() and Sandbox.get(). */ export async function configureStopHookInSandbox(sandbox: RunnableSandbox, enabled: boolean): Promise { - if (enabled) { - await sandbox.runCommand("bash", [ - "-c", - [ - `mkdir -p ~/.claude`, - `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, - `#!/bin/bash`, - `input=$(cat)`, - `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, - `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, - `if [ -n "$changes" ]; then`, - ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, - ` exit 2`, - `fi`, - `SCRIPT`, - `chmod +x ~/.claude/commit-guard.sh`, - `cat > ~/.claude/settings.json << 'JSON'`, - `{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`, - `JSON`, - ].join("\n"), - ]); - } else { - await sandbox.runCommand("bash", [ - "-c", - `mkdir -p ~/.claude && echo '{}' > ~/.claude/settings.json`, - ]); - } + // Ensure the commit-guard script exists before toggling the hook (idempotent). + await sandbox.runCommand("bash", [ + "-c", + [ + `mkdir -p ~/.claude`, + `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, + `#!/bin/bash`, + `input=$(cat)`, + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + ` exit 2`, + `fi`, + `SCRIPT`, + `chmod +x ~/.claude/commit-guard.sh`, + ].join("\n"), + ]); + + await writeClaudeSettings(sandbox, { commitGuard: enabled ? "enable" : "disable" }); } export class SandboxManager { @@ -205,9 +271,90 @@ export class SandboxManager { // Install skills globally (outside the client repo) await this.installGlobalSkills(sandbox); + await this.installArthurTracer(sandbox); + return sandbox; } + /** + * Install the Arthur AI Engine Claude Code tracer into the sandbox. + * + * No-op if the three credentials are not all configured on the SandboxManager. + * The tracer hooks into every Claude Code turn and exports OpenInference spans + * via OTLP/HTTP to the configured endpoint. + * + * If pip install fails (e.g. missing python3, offline), we log and return + * without registering hooks — failing hooks would block Claude Code turns. + */ + private async installArthurTracer(sandbox: SandboxInstance): Promise { + const { logger } = await import("../lib/logger.js"); + const arthur = this.config.arthur; + if (!arthur) { + logger.info({}, "arthur_install_skipped_no_config"); + return; + } + + logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId }, "arthur_install_started"); + + // The Vercel node24 sandbox ships python3 but no pip. Bootstrap it via + // ensurepip (idempotent), then install the two OpenTelemetry packages. + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", + ]); + if (pip.exitCode !== 0) { + const err = (await pip.stderr()).trim(); + logger.warn({ err: err.slice(0, 500) }, "arthur_pip_install_failed"); + return; + } + + // Tracer + config must all land successfully before we register hooks. + // A partial install (e.g. mv fails after pip succeeds) would leave hooks + // pointing at a missing tracer file and break every Claude Code turn. + try { + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([ + { path: "/tmp/arthur-tracer.py", content: tracerBytes }, + ]); + const mvTracer = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py", + ]); + if (mvTracer.exitCode !== 0) { + const err = (await mvTracer.stderr()).trim(); + logger.warn({ err: err.slice(0, 500) }, "arthur_tracer_install_failed"); + return; + } + + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, + null, + 2, + ); + await sandbox.writeFiles([ + { path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }, + ]); + const mvConfig = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json", + ]); + if (mvConfig.exitCode !== 0) { + const err = (await mvConfig.stderr()).trim(); + logger.warn({ err: err.slice(0, 500) }, "arthur_config_install_failed"); + return; + } + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "arthur_tracer_install_failed", + ); + return; + } + + await writeClaudeSettings(sandbox, { arthur: "install" }); + logger.info({}, "arthur_install_complete"); + } + /** * Install Claude Code skills globally in the sandbox (~/.claude/skills/). * Global install keeps the client repo completely untouched. diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 72bb64c..b8aed74 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -134,8 +134,33 @@ async function fetchPRContext(branchName: string): Promise<{ return { prComments, hasConflicts, checkResults }; } +async function ensureArthurTaskForTicket( + ticketIdentifier: string, +): Promise { + "use step"; + const { env } = await import("../../env.js"); + if (!env.GENAI_ENGINE_API_KEY || !env.GENAI_ENGINE_TRACE_ENDPOINT) return null; + + const { logger } = await import("../lib/logger.js"); + const { ArthurClient } = await import("../sandbox/arthur-client.js"); + const client = ArthurClient.fromTraceEndpoint(env.GENAI_ENGINE_TRACE_ENDPOINT, env.GENAI_ENGINE_API_KEY); + try { + const task = await client.ensureTaskForTicket(ticketIdentifier); + logger.info({ taskId: task.id, taskName: task.name, ticketIdentifier }, "arthur_task_created"); + return task.id; + } catch (err) { + logger.warn( + { err: (err as Error).message, ticketIdentifier }, + "arthur_task_create_failed", + ); + return null; + } +} +ensureArthurTaskForTicket.maxRetries = 0; + async function provisionSandbox( branchName: string, + arthurTaskId: string | null, mergeBase?: string, ): Promise { "use step"; @@ -156,6 +181,15 @@ async function provisionSandbox( ); } + const arthur = + env.GENAI_ENGINE_API_KEY && env.GENAI_ENGINE_TRACE_ENDPOINT && arthurTaskId + ? { + apiKey: env.GENAI_ENGINE_API_KEY, + taskId: arthurTaskId, + endpoint: env.GENAI_ENGINE_TRACE_ENDPOINT, + } + : undefined; + const manager = new SandboxManager({ kind: vcs.kind, token: vcs.token, @@ -167,6 +201,7 @@ async function provisionSandbox( commitAuthor: env.COMMIT_AUTHOR, commitEmail: env.COMMIT_EMAIL, jobTimeoutMs: env.JOB_TIMEOUT_MS, + arthur, }); const sandbox = await manager.provision(branchName, mergeBase); @@ -318,7 +353,6 @@ export async function agentWorkflow(ticketId: string) { "use workflow"; const { env, getVcsConfig } = await import("../../env.js"); - const { getPrompt } = await import("../lib/prompts.js"); const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); const { parseResearchStatus, parseAgentOutput, parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = await import("../sandbox/agent-runner.js"); @@ -332,6 +366,9 @@ export async function agentWorkflow(ticketId: string) { const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return; + const { loadPrompts } = await import("./prompts-step.js"); + const prompts = await loadPrompts(); + const phaseUsages: Record = {}; const usageSuffix = () => Object.keys(phaseUsages).length @@ -360,8 +397,11 @@ export async function agentWorkflow(ticketId: string) { const downloadedAttachments = await fetchAttachments(ticket.identifier, ticket.attachments); + // One Arthur task per run: first run = ticket identifier, re-runs = identifier.N + const arthurTaskId = await ensureArthurTaskForTicket(ticket.identifier); + // Provision sandbox once for all phases - const sandboxId = await provisionSandbox(branchName, mergeBase); + const sandboxId = await provisionSandbox(branchName, arthurTaskId, mergeBase); // Pin the sandboxId to this ticket so cleanup paths (reconcile, // cancelRun, webhook-cancel) can stop it by id instead of doing a // branch scan across every running sandbox. @@ -383,7 +423,7 @@ export async function agentWorkflow(ticketId: string) { const researchInput = assembleResearchPlanContext({ ticket: ticketData, - prompt: getPrompt("research-plan.md"), + prompt: prompts.research, branchName, prComments: prContext?.prComments, checkResults: prContext?.checkResults, @@ -445,7 +485,7 @@ export async function agentWorkflow(ticketId: string) { const implInput = assembleImplementationContext({ ticket: ticketData, - prompt: getPrompt("implement.md"), + prompt: prompts.implement, researchPlanMarkdown, attachments: downloadedAttachments, }); @@ -503,7 +543,7 @@ export async function agentWorkflow(ticketId: string) { // // const reviewInput = assembleReviewContext({ // ticket: ticketData, - // prompt: getPrompt("review.md"), + // prompt: prompts.review, // researchPlanMarkdown, // gitDiff, // attachments: downloadedAttachments, diff --git a/src/workflows/prompts-step.test.ts b/src/workflows/prompts-step.test.ts new file mode 100644 index 0000000..ab09934 --- /dev/null +++ b/src/workflows/prompts-step.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../env.js", () => ({ env: {} })); + +const mockGetPromptByTag = vi.fn(); +vi.mock("../sandbox/arthur-client.js", () => ({ + ArthurClient: { + fromTraceEndpoint: vi.fn(() => ({ getPromptByTag: mockGetPromptByTag })), + }, +})); + +import { loadPrompts } from "./prompts-step.js"; +import { PROMPT_FALLBACKS } from "../lib/prompts.js"; + +async function setEnv(partial: Record) { + const mod = (await import("../../env.js")) as unknown as { env: Record }; + mod.env = { ...mod.env, ...partial }; +} + +describe("loadPrompts", () => { + beforeEach(async () => { + mockGetPromptByTag.mockReset(); + await setEnv({ + GENAI_ENGINE_API_KEY: undefined, + GENAI_ENGINE_TRACE_ENDPOINT: undefined, + GENAI_ENGINE_PROMPT_TASK_ID: undefined, + }); + }); + + it("returns fallbacks when no Arthur env is set", async () => { + const result = await loadPrompts(); + expect(result.research).toBe(PROMPT_FALLBACKS["research-plan"]); + expect(result.implement).toBe(PROMPT_FALLBACKS["implement"]); + expect(result.review).toBe(PROMPT_FALLBACKS["review"]); + expect(mockGetPromptByTag).not.toHaveBeenCalled(); + }); + + it("returns fallbacks when PROMPT_TASK_ID is missing even if key+endpoint are set", async () => { + await setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: undefined, + }); + const result = await loadPrompts(); + expect(result.research).toBe(PROMPT_FALLBACKS["research-plan"]); + expect(mockGetPromptByTag).not.toHaveBeenCalled(); + }); + + it("returns Arthur prompts when all three are present", async () => { + await setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: "prompt-task-uuid", + }); + mockGetPromptByTag + .mockResolvedValueOnce("arthur research") + .mockResolvedValueOnce("arthur implement") + .mockResolvedValueOnce("arthur review"); + const result = await loadPrompts(); + expect(result).toEqual({ + research: "arthur research", + implement: "arthur implement", + review: "arthur review", + }); + expect(mockGetPromptByTag).toHaveBeenCalledTimes(3); + const names = mockGetPromptByTag.mock.calls.map((c) => c[1]); + expect(names).toEqual(["research-plan", "implement", "review"]); + }); + + it("falls back per-prompt when Arthur returns null or throws", async () => { + await setEnv({ + GENAI_ENGINE_API_KEY: "k", + GENAI_ENGINE_TRACE_ENDPOINT: "https://host/api/v1/traces", + GENAI_ENGINE_PROMPT_TASK_ID: "prompt-task-uuid", + }); + mockGetPromptByTag + .mockResolvedValueOnce("arthur research") + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error("boom")); + + const result = await loadPrompts(); + expect(result.research).toBe("arthur research"); + expect(result.implement).toBe(PROMPT_FALLBACKS["implement"]); + expect(result.review).toBe(PROMPT_FALLBACKS["review"]); + }); +}); diff --git a/src/workflows/prompts-step.ts b/src/workflows/prompts-step.ts new file mode 100644 index 0000000..9baae40 --- /dev/null +++ b/src/workflows/prompts-step.ts @@ -0,0 +1,58 @@ +export interface LoadedPrompts { + research: string; + implement: string; + review: string; +} + +export async function loadPrompts(): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { logger } = await import("../lib/logger.js"); + const { PROMPT_FALLBACKS } = await import("../lib/prompts.js"); + type PromptName = keyof typeof PROMPT_FALLBACKS; + + const arthurEnabled = + !!env.GENAI_ENGINE_API_KEY && + !!env.GENAI_ENGINE_TRACE_ENDPOINT && + !!env.GENAI_ENGINE_PROMPT_TASK_ID; + + if (!arthurEnabled) { + logger.info({ source: "fallback", reason: "arthur_prompts_disabled" }, "prompts_loaded"); + return { + research: PROMPT_FALLBACKS["research-plan"], + implement: PROMPT_FALLBACKS["implement"], + review: PROMPT_FALLBACKS["review"], + }; + } + + const { ArthurClient } = await import("../sandbox/arthur-client.js"); + const client = ArthurClient.fromTraceEndpoint( + env.GENAI_ENGINE_TRACE_ENDPOINT!, + env.GENAI_ENGINE_API_KEY!, + ); + const taskId = env.GENAI_ENGINE_PROMPT_TASK_ID!; + const TAG = "production"; + + async function one(name: PromptName): Promise { + try { + const body = await client.getPromptByTag(taskId, name, TAG); + if (body === null) { + logger.info({ name, source: "fallback", reason: "arthur_prompt_missing" }, "prompts_loaded"); + return PROMPT_FALLBACKS[name]; + } + logger.info({ name, source: "arthur" }, "prompts_loaded"); + return body; + } catch (err) { + logger.warn({ name, source: "fallback", err: (err as Error).message }, "prompts_loaded"); + return PROMPT_FALLBACKS[name]; + } + } + + const [research, implement, review] = await Promise.all([ + one("research-plan"), + one("implement"), + one("review"), + ]); + return { research, implement, review }; +} +loadPrompts.maxRetries = 0; From 51e2ff335fd9f946da937d1e506e2a477154f6eb Mon Sep 17 00:00:00 2001 From: kasin-it Date: Mon, 27 Apr 2026 13:45:11 +0200 Subject: [PATCH 68/71] docs: add setup onboarding plan --- .../2026-04-27-setup-onboarding-research.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-setup-onboarding-research.md diff --git a/docs/superpowers/specs/2026-04-27-setup-onboarding-research.md b/docs/superpowers/specs/2026-04-27-setup-onboarding-research.md new file mode 100644 index 0000000..13b1768 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-setup-onboarding-research.md @@ -0,0 +1,169 @@ +# Setup Onboarding — What We Can and Can't Automate + +**Date**: 2026-04-27 · **Status**: Research + +## TL;DR + +The biggest single UX win is a **Vercel Deploy Button** that imports the repo, provisions Upstash +Redis via Marketplace, prompts for credentials, and deploys — all in one click. The remaining +manual work is per-provider token minting (the operator owns the accounts, not us). Sections +below are ordered by install flow: host first, then everything fed into it. + +> Script names below (`pnpm setup`, `pnpm setup:check`) are proposed. Today only +> `pnpm setup:arthur-prompts` exists. + +--- + +## Vercel + +**Can't:** create the Vercel team, run `vercel login` for the operator. + +**Can:** + +- **Deploy Button + Marketplace stores** — one URL imports the repo, creates the project, + provisions Upstash Redis (and optionally Postgres), prompts for env vars, deploys: + ``` + https://vercel.com/new/clone + ?repository-url=&project-name=blazebot + &env=&envDescription= + &stores=[{"type":"integration","integrationSlug":"upstash","productSlug":"upstash-kv-redis"}] + ``` +- `vercel link` wrapper, `vercel env add` / `pull` automation, env-drift diff between local and + prod. + +--- + +## Upstash Redis + +**Can't** (outside Vercel): account creation, dashboard provisioning. Inside Vercel Marketplace, +the operator never touches Upstash directly. + +**Can:** + +- Marketplace install during project creation auto-injects connection env vars. +- Connection + round-trip tests, namespace prefix derived from project name. + +> **Precondition:** rename `AI_WORKFLOW_KV_REST_API_URL` / `_TOKEN` to the Marketplace defaults +> (`KV_REST_API_URL` / `_TOKEN` or `UPSTASH_REDIS_REST_URL` / `_TOKEN`). ~30-min change. + +--- + +## Jira + +**Can't:** Atlassian account, API token, project selection, the operator's intent of which status +maps to which role. + +**Can:** + +- Token + project-access validation. +- **Status → role mapping** — fetch project statuses, show three dropdowns (Active / Review / + Backlog), write `COLUMN_AI` / `COLUMN_AI_REVIEW` / `COLUMN_BACKLOG`. +- **Missing-status helper** — team-managed projects: create via REST; company-managed (most + enterprises): print exact UI steps to take, then re-run. +- Auto-generate `JIRA_WEBHOOK_SECRET`. + +**Can't (without a Connect/Forge app):** programmatic webhook registration. Operator clicks +through Jira's Webhooks UI by hand. + +**Setup splits in two:** pre-deploy (token, statuses, secret) → post-deploy (webhook URL needs +the deployed domain). Cron polling works without the webhook, so the post-deploy step is optional. + +--- + +## GitHub / GitLab + +**Can't:** account, PAT minting, repo selection. + +**Can:** token + repo validation, push-permission probe (create + delete throwaway branch), +base-branch auto-discovery, PAT-creation deep-link. + +--- + +## Slack + +**Can't:** workspace creation, app-install consent. + +**Can:** + +- **App manifest install** — one URL with scopes pre-set. ~6 manual clicks → 2. +- Token validation, channel pick by name, bot-membership probe with `/invite` instructions. + +--- + +## Anthropic / Claude Code + +**Can't:** account, key issuance. + +**Can:** key validation against `/v1/models`, accept either API key or `claude setup-token` OAuth, +**model selection** from the live `/v1/models` list (defaults to `CLAUDE_MODEL` in `env.ts`). + +--- + +## Secrets + +`CRON_SECRET`, `JIRA_WEBHOOK_SECRET` — auto-generate via `openssl rand -hex 32`. Operator never +sees them. + +--- + +## Arthur (optional) + +**Can't:** account, key issuance. + +**Can:** skip-if-unconfigured, key validation, idempotent prompt-task creation +(`pnpm setup:arthur-prompts` already does this). + +> Future: the wizard could call this script automatically to fully scaffold Arthur tracing + +> hosted prompts in one step. Out of scope for the first cut — left as a follow-up. + +--- + +## Happy path (if everything above ships) + +A new operator's full setup, end to end: + +1. **One-time accounts.** Operator has Vercel, Atlassian, GitHub, Slack, Anthropic accounts and + mints tokens for each. ~5 min, outside our control. + +2. **`pnpm setup`** — interactive wizard, ~3 min: + - Auto-generates `CRON_SECRET` and `JIRA_WEBHOOK_SECRET`. + - Validates each token live as it's pasted. + - Fetches Jira statuses → three dropdowns for Active / Review / Backlog. + - Opens Slack manifest install URL → bot token returned → channel picked by name. + - Anthropic model picker from live `/v1/models`. + - Writes a complete `.env.local`. + +3. **One-click deploy** — wizard ends with a `[Deploy]` step (or operator clicks the README's + Deploy Button): + - Repo imported, project created, Upstash Redis provisioned via Marketplace (env vars + auto-injected). + - Wizard pushes the rest of `.env` to Vercel. + - Cron registered. First poll within 60s. + - Deployment URL returned to the wizard. + +4. **`pnpm setup:webhook`** — post-deploy continuation, ~30 sec: + - Prints the Jira webhook URL + secret to paste. + - Operator opens Jira → Webhooks → Add → Save. + - Wizard verifies the first delivery. + +5. **Done.** Drop a Jira ticket into the AI column; first PR comes back within minutes. + +**Operator time after the one-time account setup: ~5 minutes.** Compare to today's manual flow — +~30 minutes of typing 18 env vars across five service dashboards. + +What still requires the operator: account creation, token minting (we can never do these), and +three consent screens (Vercel project, Slack install, Jira webhook). + +### Rough estimate + +| Step | Operator-active | Wall-clock | +| --- | --- | --- | +| 1 — One-time accounts + tokens | ~5 min | ~5 min | +| 2 — `pnpm setup` wizard | ~3 min | ~3 min | +| 3 — Deploy click | ~30 sec | ~1–2 min (Vercel build) | +| 4 — `pnpm setup:webhook` | ~30 sec | ~30 sec | +| 5 — First ticket → first PR | — | ~few min (cron + agent run) | + +**Totals:** ~9 min operator-active for a fresh operator, ~4 min if accounts already exist; +~10–12 min wall-clock to a working install. Today: ~30 min operator-active, ~35 min wall-clock — +roughly a 3× speedup end-to-end and 6–7× less typing. From 5561ec35f57be4355207072324f16505cbffb1ad Mon Sep 17 00:00:00 2001 From: Kacper <89151689+kasin-it@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:41:26 +0200 Subject: [PATCH 69/71] Aiw 1 codex (#59) * feat: add codex * fix: codex impl * feat: strip json validation logic for claude --- .claude/learnings.md | 10 + .env.example | 10 + .github/workflows/e2e.yml | 8 + README.md | 26 +- .../plans/2026-04-27-codex-integration.md | 2600 +++++++++++++++++ .../2026-04-27-codex-integration-design.md | 423 +++ e2e/helpers/jira.ts | 16 +- env.ts | 29 + scripts/test-sandbox-skills.ts | 2 - src/adapters/issue-tracker/jira.test.ts | 33 + src/adapters/issue-tracker/jira.ts | 19 +- src/lib/prompts.ts | 68 +- src/sandbox/agent-runner.test.ts | 242 -- src/sandbox/agent-runner.ts | 217 -- src/sandbox/agents/claude.test.ts | 317 ++ src/sandbox/agents/claude.ts | 334 +++ src/sandbox/agents/codex.test.ts | 239 ++ src/sandbox/agents/codex.ts | 413 +++ src/sandbox/agents/index.test.ts | 62 + src/sandbox/agents/index.ts | 39 + src/sandbox/agents/pricing.test.ts | 50 + src/sandbox/agents/pricing.ts | 55 + src/sandbox/agents/shared.test.ts | 33 + src/sandbox/agents/shared.ts | 40 + src/sandbox/agents/types.ts | 141 + src/sandbox/manager.test.ts | 321 +- src/sandbox/manager.ts | 341 +-- src/sandbox/poll-agent.test.ts | 90 +- src/sandbox/poll-agent.ts | 43 +- src/sandbox/usage.test.ts | 177 +- src/sandbox/usage.ts | 135 +- src/sandbox/wrapper-script.test.ts | 105 - src/sandbox/wrapper-script.ts | 49 - src/workflows/agent.ts | 214 +- 34 files changed, 5356 insertions(+), 1545 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-27-codex-integration.md create mode 100644 docs/superpowers/specs/2026-04-27-codex-integration-design.md delete mode 100644 src/sandbox/agent-runner.test.ts delete mode 100644 src/sandbox/agent-runner.ts create mode 100644 src/sandbox/agents/claude.test.ts create mode 100644 src/sandbox/agents/claude.ts create mode 100644 src/sandbox/agents/codex.test.ts create mode 100644 src/sandbox/agents/codex.ts create mode 100644 src/sandbox/agents/index.test.ts create mode 100644 src/sandbox/agents/index.ts create mode 100644 src/sandbox/agents/pricing.test.ts create mode 100644 src/sandbox/agents/pricing.ts create mode 100644 src/sandbox/agents/shared.test.ts create mode 100644 src/sandbox/agents/shared.ts create mode 100644 src/sandbox/agents/types.ts delete mode 100644 src/sandbox/wrapper-script.test.ts delete mode 100644 src/sandbox/wrapper-script.ts diff --git a/.claude/learnings.md b/.claude/learnings.md index a6e1a3b..74171ea 100644 --- a/.claude/learnings.md +++ b/.claude/learnings.md @@ -8,6 +8,16 @@ - `@vercel/sandbox` git clones can be shallow by default, causing "no history in common with main" on PR creation when force-pushing from the sandbox. Always unshallow before pushing (`git fetch --unshallow origin`). - `GitHubAdapter.createBranch` must force-reset existing branches to the base SHA on 422, not silently return. Stale branches from previous failed runs can retain orphan history. +## Jira adapter +- Jira REST v3 comments require ADF, and **ADF text nodes cannot contain `\n`**. Multi-line content must be modeled as multiple paragraph nodes (or use `hardBreak` inline nodes between text nodes). Stuffing newline-joined text into a single text node returns 400 Bad Request on `/rest/api/3/issue/{id}/comment`. Adapter helper `toAdfParagraphs` splits on `\n` and emits one paragraph per line. + +## Codex agent in Vercel Sandbox +- The `using-git-worktrees` superpowers skill is the dominant root cause of empty-PR / `.gitignore`-only commits, NOT `.codex/` pollution. The skill's contract: "If the worktree directory is not in .gitignore, add it and commit before proceeding." The `executing-plans` skill REQUIRES `using-git-worktrees`, so any prompt that tells the agent to use `executing-plans` chains into "modify .gitignore + commit" before any real implementation work. Confirmed on AWT-641 / AWT-642. Mitigation: prompts (research + implement) explicitly forbid invoking `using-git-worktrees`/`executing-plans` and forbid any `git worktree` command or `.gitignore` change. The block is in the prompt body and must override conflicting skill text. +- Codex CLI also creates `.codex/` in cwd at runtime (per-session state). Without intervention, the agent sees it as untracked pollution in `git status`, "fixes" it by adding `.codex/` to `.gitignore`, commits only that. Mitigation: `CodexAgentAdapter.configure` writes `.codex/` to `.git/info/exclude` so it's hidden from the agent's git status. The post-phase cleanup `rm -rf .codex/` exists for the same reason but only runs after the agent exits. +- `extractUsage` cannot derive duration from Codex NDJSON event timestamps (the events do not carry `timestamp`/`ts`/`time` keys). The wrapper script appends a synthetic `{"type":"phase.duration","duration_ms":N}` line to stdout as a fallback so Slack reports show real wall-clock minutes instead of `0m`. +- Codex Stop-hook protocol accepts BOTH `{"decision":"block","reason":"..."}` (legacy) and `{"continue":true,"stopReason":"..."}` (new) on stdout with exit 0 to force the agent to take another turn — confirmed against developers.openai.com/codex/hooks. Either format works; the codebase uses the legacy one for parity with the Claude commit-guard. +- `fixAndRetryPush` must dispatch the configured agent's CLI, not hardcode `claude`. When AGENT_KIND=codex the claude binary isn't installed and `|| true` swallows the failure, leaving the same broken HEAD to be force-pushed. + ## E2E in GitHub Actions - `@vercel/sandbox` reads credentials from `process.env` — a GH secret is not enough; it must be mapped via the job's `env:` block (e.g. `VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }}`). Prefer long-lived `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` over OIDC — OIDC tokens expire in ~12h and the SDK's refresh path requires `.vercel/project.json`, which CI doesn't have. - Reconcile (`src/lib/reconcile.ts`) has a 30s `ORPHAN_GRACE_MS` window that skips entries younger than 30s. Any e2e test seeding a registry entry via `setEntry` and expecting reconcile to cancel it on the next cron tick must backdate the timestamp past the grace window (`setEntry(key, runId, { ageMs: 60_000 })`). Without backdating the test is racy — it only passes if Vercel's 1-min scheduled cron happens to fire at T>30s during the test's wait window. diff --git a/.env.example b/.env.example index 149cc3e..3f3fb91 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,16 @@ CLAUDE_MODEL=claude-opus-4-6 COMMIT_AUTHOR=ai-workflow-blazity COMMIT_EMAIL=ai-workflow@blazity.com +# Agent — choose runtime (claude | codex). Defaults to claude. +AGENT_KIND=claude + +# Codex (only when AGENT_KIND=codex) +# CODEX_API_KEY= +# CODEX_CHATGPT_OAUTH_TOKEN= # alternative to CODEX_API_KEY +# CODEX_MODEL=gpt-5-codex +# CODEX_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json +# CODEX_PRICING_TTL_MS=3600000 + # Arthur AI Engine (optional — tracing + hosted prompts) # Set both API_KEY and TRACE_ENDPOINT to install the tracer into every sandbox. # Set PROMPT_TASK_ID after running `npx tsx scripts/setup-arthur-prompts.ts`. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 09bdc7f..4b7719c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,13 @@ on: - agent - all default: all + agent: + description: "Which agent the agent-tier should run under (per-ticket label override)" + type: choice + options: + - claude + - codex + default: claude jobs: e2e-orchestration: @@ -148,6 +155,7 @@ jobs: VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} MAX_CONCURRENT_AGENTS: ${{ secrets.MAX_CONCURRENT_AGENTS }} + E2E_AGENT_KIND: ${{ inputs.agent }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/README.md b/README.md index b424a0c..b696bf9 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,23 @@ COMMIT_AUTHOR=ai-workflow-blazity # Git commit author name COMMIT_EMAIL=ai-workflow@blazity.com # Git commit author email ``` +**Switching agents** — Blazebot supports two CLI runtimes. Set `AGENT_KIND` once per deployment: + +```bash +AGENT_KIND=claude # default — Anthropic Claude Code +# or +AGENT_KIND=codex # OpenAI Codex CLI +``` + +When `AGENT_KIND=codex`: + +```bash +CODEX_API_KEY=sk-codex-xxxxxxxxxxxx # or CODEX_CHATGPT_OAUTH_TOKEN +CODEX_MODEL=gpt-5-codex # default +``` + +Pricing is fetched from [LiteLLM's community-maintained JSON](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) on each cold start (1h TTL by default). Override `CODEX_PRICING_URL` in air-gapped environments. When pricing is unavailable, Slack reports show tokens-only with `cost unknown`. + **Sandbox** — Concurrency and timeout limits: ```bash MAX_CONCURRENT_AGENTS=3 # Max parallel sandboxes (default: 3) @@ -222,8 +239,15 @@ curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/cron/poll | `CHAT_SDK_CHANNEL_ID` | Yes | — | Notification channel ID | | `CHAT_SDK_BOT_NAME` | No | `blazebot` | Bot display name | | **Agent** | | | | -| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key | +| `AGENT_KIND` | No | `claude` | Runtime: `claude` or `codex` | +| `ANTHROPIC_API_KEY` | Yes* | — | Anthropic API key (required when `AGENT_KIND=claude`) | +| `CLAUDE_CODE_OAUTH_TOKEN` | No | — | Alternative to `ANTHROPIC_API_KEY` | | `CLAUDE_MODEL` | No | `claude-opus-4-6` | Claude model ID | +| `CODEX_API_KEY` | Yes* | — | OpenAI Codex API key (required when `AGENT_KIND=codex`) | +| `CODEX_CHATGPT_OAUTH_TOKEN` | No | — | Alternative to `CODEX_API_KEY` | +| `CODEX_MODEL` | No | `gpt-5-codex` | Codex model ID | +| `CODEX_PRICING_URL` | No | LiteLLM JSON | Pricing source for Codex cost reporting | +| `CODEX_PRICING_TTL_MS` | No | `3600000` | Pricing cache TTL (ms) | | `COMMIT_AUTHOR` | No | `ai-workflow-blazity` | Git author name | | `COMMIT_EMAIL` | No | `ai-workflow@blazity.com` | Git author email | | **Sandbox** | | | | diff --git a/docs/superpowers/plans/2026-04-27-codex-integration.md b/docs/superpowers/plans/2026-04-27-codex-integration.md new file mode 100644 index 0000000..bf89a7c --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-codex-integration.md @@ -0,0 +1,2600 @@ +# Codex Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenAI's Codex CLI as a second agent runtime alongside Claude Code, env-switched via `AGENT_KIND=claude|codex`, with full feature parity (skills, commit-guard, Arthur tracing, structured output, usage reporting). + +**Architecture:** Introduce a thin `AgentAdapter` interface in `src/sandbox/agents/`. Refactor existing Claude logic into `ClaudeAgentAdapter`. Add `CodexAgentAdapter` that wraps `codex exec --json --output-schema`. `SandboxManager` becomes thin and orchestrator-only. Workflow code threads the adapter through phase steps without changing shape. + +**Tech Stack:** TypeScript / Node 24 / Vercel Sandbox / Vercel Workflow / Vitest / Zod / `@anthropic-ai/claude-code` / `@openai/codex` (new) / LiteLLM model pricing JSON (new). + +**Source spec:** `docs/superpowers/specs/2026-04-27-codex-integration-design.md`. + +--- + +## Phase 1 — Refactor (Claude only, no Codex yet) + +Goal: extract the Claude-specific bits behind an `AgentAdapter`. Existing tests + the e2e Claude path keep passing. Ship as one logical commit at the end of Phase 1. + +### Task 1: Scaffold `agents/types.ts` — interface + shared types + +**Files:** +- Create: `src/sandbox/agents/types.ts` + +- [ ] **Step 1: Write the types module** + +```ts +// src/sandbox/agents/types.ts +import type { Sandbox as SandboxType } from "@vercel/sandbox"; +import { z } from "zod"; + +export type PhaseKind = "research" | "impl" | "review"; + +type SandboxInstance = Awaited>; + +/** Minimal interface for sandbox objects that support runCommand and writeFiles. */ +export interface RunnableSandbox { + runCommand: SandboxInstance["runCommand"]; + writeFiles: SandboxInstance["writeFiles"]; +} + +// --- Schemas (moved from src/sandbox/agent-runner.ts) --- + +export const agentOutputSchema = z.object({ + result: z.enum(["implemented", "clarification_needed", "failed"]), + summary: z.string().optional(), + questions: z.array(z.string()).optional(), + error: z.string().optional(), +}); +export type AgentOutput = z.infer; + +export const AGENT_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { type: "string", enum: ["implemented", "clarification_needed", "failed"] }, + summary: { type: "string" }, + questions: { type: "array", items: { type: "string" } }, + error: { type: "string" }, + }, + required: ["result"], +}); + +export const reviewOutputSchema = z.object({ + result: z.enum(["approved", "failed"]), + feedback: z.string(), + issues: z.array(z.object({ + file: z.string(), + description: z.string(), + severity: z.enum(["critical", "suggestion"]), + })), + error: z.string().optional(), +}); +export type ReviewOutput = z.infer; + +export const REVIEW_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { type: "string", enum: ["approved", "failed"] }, + feedback: { type: "string" }, + issues: { + type: "array", + items: { + type: "object", + properties: { + file: { type: "string" }, + description: { type: "string" }, + severity: { type: "string", enum: ["critical", "suggestion"] }, + }, + required: ["file", "description", "severity"], + }, + }, + error: { type: "string" }, + }, + required: ["result", "feedback", "issues"], +}); + +export type ResearchStatus = "completed" | "clarification_needed" | "failed"; +export interface ResearchResult { status: ResearchStatus; body: string; } + +// --- Usage (replaces shape in src/sandbox/usage.ts) --- + +export interface PhaseUsage { + /** Populated by Claude (CLI computes dollars itself). null for Codex (computed downstream from tokens). */ + cost_usd: number | null; + /** Populated by Codex from turn.completed. null for Claude. */ + tokens: { input: number; cached_input: number; output: number } | null; + duration_ms: number; + duration_api_ms: number; + num_turns: number; +} + +// --- Adapter contract --- + +export interface ArthurConfig { + apiKey: string; + taskId: string; + endpoint: string; +} + +export interface ConfigureOpts { + anthropicApiKey?: string; + claudeCodeOauthToken?: string; + codexApiKey?: string; + codexChatGptOauthToken?: string; + model: string; + arthur?: ArthurConfig; +} + +export interface PhaseArtifactPaths { + wrapper: string; + input: string; + stdout: string; + stderr: string; + sentinel: string; + /** Schema-validated JSON file (Codex --output-schema). null for Claude. */ + structuredOutput: string | null; +} + +export interface PhaseScriptOpts { + phase: PhaseKind; + model: string; + paths: PhaseArtifactPaths; + /** When set, the phase requests schema-validated structured output. */ + jsonSchema?: string; +} + +export interface AgentAdapter { + kind: "claude" | "codex"; + install(sandbox: RunnableSandbox): Promise; + configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise; + setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise; + buildPhaseScript(opts: PhaseScriptOpts): string; + artifactPaths(phase: PhaseKind): PhaseArtifactPaths; + parseAgentOutput(raw: string, structured: string | null): AgentOutput; + parseReviewOutput(raw: string, structured: string | null): ReviewOutput; + parseResearchStatus(raw: string, structured: string | null): ResearchResult; + extractUsage(raw: string, structured: string | null): PhaseUsage | null; +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS (file is types-only, no behavior; new dir does not break anything yet). + +- [ ] **Step 3: Commit (deferred — see Phase 1 commit at the end)** + +--- + +### Task 2: Move shared install logic into `agents/shared.ts` + +**Files:** +- Create: `src/sandbox/agents/shared.ts` +- Create: `src/sandbox/agents/shared.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/sandbox/agents/shared.test.ts +import { describe, it, expect, vi } from "vitest"; +import { GLOBAL_SKILLS, installSkillsToAgentsDir } from "./shared.js"; + +describe("GLOBAL_SKILLS", () => { + it("contains the expected skill repos", () => { + const ids = GLOBAL_SKILLS.map((s) => `${s.repo}#${s.skill}`); + expect(ids).toContain("https://github.com/obra/superpowers#using-superpowers"); + expect(ids).toContain("https://github.com/obra/superpowers#requesting-code-review"); + expect(ids).toContain("https://github.com/anthropics/skills#frontend-design"); + }); +}); + +describe("installSkillsToAgentsDir", () => { + it("runs `npx skills add --skill --target ~/.agents/skills` for each entry", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const writeFiles = vi.fn().mockResolvedValue(undefined); + const sandbox = { runCommand, writeFiles } as any; + + await installSkillsToAgentsDir(sandbox); + + const calls = runCommand.mock.calls.filter((c) => c[0] === "npx"); + expect(calls).toHaveLength(GLOBAL_SKILLS.length); + for (const [_, args] of calls) { + expect(args).toContain("skills"); + expect(args).toContain("add"); + expect(args).toContain("--target"); + expect(args).toContain("$HOME/.agents/skills"); + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/agents/shared.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the shared module** + +```ts +// src/sandbox/agents/shared.ts +import type { RunnableSandbox } from "./types.js"; + +/** + * Skills installed globally in every sandbox under ~/.agents/skills/. + * Both adapters read from this single path; Claude additionally symlinks + * ~/.claude/skills → ~/.agents/skills so its auto-discovery finds the same content. + */ +export const GLOBAL_SKILLS = [ + { repo: "https://github.com/obra/superpowers", skill: "using-superpowers" }, + { repo: "https://github.com/obra/superpowers", skill: "requesting-code-review" }, + { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, +] as const; + +/** + * Install every entry in GLOBAL_SKILLS into ~/.agents/skills inside a sandbox. + * + * Uses `--target` so both Claude (~/.claude/skills via symlink) and Codex + * (native ~/.agents/skills) read the same set without duplication. + */ +export async function installSkillsToAgentsDir(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("bash", ["-c", "mkdir -p $HOME/.agents/skills"]); + for (const { repo, skill } of GLOBAL_SKILLS) { + await sandbox.runCommand("npx", [ + "-y", "skills", "add", repo, + "--skill", skill, + "--yes", + "--target", "$HOME/.agents/skills", + ]); + } +} + +/** Bash body for the commit-guard hook. The output protocol differs between agents, + * so each adapter wraps this differently. */ +export const COMMIT_GUARD_CHECK_SH = [ + "input=$(cat)", + // Skip when re-entered (set by Claude as stop_hook_active, by us as already_blocked for Codex) + `if echo "$input" | grep -q -E '"stop_hook_active":true|"already_blocked":true'; then exit 0; fi`, + // Ignore changes inside ~/.claude/ or ~/.codex/ inside the workspace + `changes=$(git status --porcelain | grep -v -E '^.. \\.(claude|codex)/' | grep -v -E '^\\?\\? \\.(claude|codex)/' || true)`, +].join("\n"); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest run src/sandbox/agents/shared.test.ts` +Expected: PASS. + +--- + +### Task 3: Implement `agents/claude.ts` — Claude adapter + +**Files:** +- Create: `src/sandbox/agents/claude.ts` +- Create: `src/sandbox/agents/claude.test.ts` + +This task moves three families of code into the Claude adapter: + +1. The wrapper script body from `src/sandbox/wrapper-script.ts` → `buildPhaseScript()`. +2. The parsers (`parseAgentOutput`, `parseResearchStatus`, `parseReviewOutput`) and `extractUsage` from `src/sandbox/agent-runner.ts` and `src/sandbox/usage.ts` → adapter methods that ignore the `structured` argument. +3. Provisioning side effects (`installArthurTracer`, `configureStopHookInSandbox`, skill install) from `src/sandbox/manager.ts` → `install()`, `configure()`, `setCommitGuard()`. + +- [ ] **Step 1: Write `claude.test.ts` covering parsers + buildPhaseScript + setCommitGuard** + +Note: parser test cases are copied verbatim from `src/sandbox/agent-runner.test.ts` and `src/sandbox/usage.test.ts`. Recreate the same coverage so old behaviour is preserved. + +```ts +// src/sandbox/agents/claude.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ClaudeAgentAdapter } from "./claude.js"; + +const adapter = new ClaudeAgentAdapter(); + +describe("ClaudeAgentAdapter.parseAgentOutput", () => { + it("parses implemented result", () => { + const raw = JSON.stringify({ result: "implemented", summary: "done" }); + expect(adapter.parseAgentOutput(raw, null).result).toBe("implemented"); + }); + + it("parses structured_output from result envelope", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "freeform text", + structured_output: { result: "implemented", summary: "Renamed endpoint" }, + }); + expect(adapter.parseAgentOutput(envelope, null).summary).toBe("Renamed endpoint"); + }); + + it("returns failed on empty output", () => { + expect(adapter.parseAgentOutput("", null).result).toBe("failed"); + }); + + it("returns failed on garbage", () => { + const out = adapter.parseAgentOutput("not json at all", null); + expect(out.result).toBe("failed"); + expect(out.error).toContain("not structured JSON"); + }); + + it("ignores the structured argument (Claude embeds output in raw)", () => { + // Claude never receives a separate structured file; the structured arg is null in production. + const raw = JSON.stringify({ result: "implemented", summary: "via raw" }); + expect(adapter.parseAgentOutput(raw, "ignored payload").summary).toBe("via raw"); + }); +}); + +describe("ClaudeAgentAdapter.parseResearchStatus", () => { + it("parses a STATUS line and returns the body", () => { + // Claude wraps research output in a result envelope; parseResearchStatus must unwrap. + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + result: "STATUS: completed\n\nPlan body here", + }); + const r = adapter.parseResearchStatus(envelope, null); + expect(r.status).toBe("completed"); + expect(r.body).toBe("Plan body here"); + }); + + it("falls back to failed when no STATUS line is present", () => { + expect(adapter.parseResearchStatus("no status here", null).status).toBe("failed"); + }); +}); + +describe("ClaudeAgentAdapter.parseReviewOutput", () => { + it("parses approved with empty issues", () => { + const raw = JSON.stringify({ result: "approved", feedback: "looks good", issues: [] }); + expect(adapter.parseReviewOutput(raw, null).result).toBe("approved"); + }); + + it("returns failed on empty input", () => { + expect(adapter.parseReviewOutput("", null).result).toBe("failed"); + }); +}); + +describe("ClaudeAgentAdapter.extractUsage", () => { + it("extracts cost_usd from a result envelope", () => { + const raw = JSON.stringify({ + type: "result", subtype: "success", + cost_usd: 0.42, duration_ms: 60_000, duration_api_ms: 30_000, num_turns: 3, + result: "ok", + }); + const u = adapter.extractUsage(raw, null); + expect(u).toEqual({ + cost_usd: 0.42, + tokens: null, + duration_ms: 60_000, + duration_api_ms: 30_000, + num_turns: 3, + }); + }); + + it("returns null when no envelope is present", () => { + expect(adapter.extractUsage("not json", null)).toBeNull(); + }); +}); + +describe("ClaudeAgentAdapter.buildPhaseScript", () => { + it("emits a script that sources agent-env.sh and invokes claude", () => { + const paths = adapter.artifactPaths("research"); + const s = adapter.buildPhaseScript({ phase: "research", model: "claude-opus-4-6", paths }); + expect(s).toContain("#!/bin/bash"); + expect(s).toContain("claude"); + expect(s).toContain("--model 'claude-opus-4-6'"); + expect(s).toContain("--output-format json"); + expect(s).toContain("/tmp/research-requirements.md"); + expect(s).toContain("/tmp/research-stdout.txt"); + expect(s).toContain("/tmp/research-stderr.txt"); + expect(s).toContain("/tmp/research-done"); + expect(s).not.toContain("--json-schema"); + }); + + it("includes --json-schema when jsonSchema is supplied", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ + phase: "impl", + model: "claude-opus-4-6", + paths, + jsonSchema: '{"type":"object"}', + }); + expect(s).toContain("--json-schema"); + }); +}); + +describe("ClaudeAgentAdapter.artifactPaths", () => { + it("returns Claude paths with structuredOutput=null", () => { + expect(adapter.artifactPaths("research")).toEqual({ + wrapper: "/tmp/research-wrapper.sh", + input: "/tmp/research-requirements.md", + stdout: "/tmp/research-stdout.txt", + stderr: "/tmp/research-stderr.txt", + sentinel: "/tmp/research-done", + structuredOutput: null, + }); + }); +}); + +describe("ClaudeAgentAdapter.setCommitGuard", () => { + it("upserts the Stop hook when enabled and writes commit-guard.sh", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const writeFiles = vi.fn().mockResolvedValue(undefined); + const sandbox = { runCommand, writeFiles } as any; + + await adapter.setCommitGuard(sandbox, true); + + const calls = runCommand.mock.calls; + // Writes the guard script + expect(calls.some(([cmd, args]) => cmd === "bash" && args[1].includes("commit-guard.sh"))).toBe(true); + // Toggles via node merge script + const mergeCall = calls.find(([cmd, args]) => + cmd === "node" && args[1] === "-e" && args[2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); + }); + + it("disables by writing commitGuard=disable directive", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandbox = { runCommand, writeFiles: vi.fn() } as any; + + await adapter.setCommitGuard(sandbox, false); + + const mergeCall = runCommand.mock.calls.find(([cmd, args]) => + cmd === "node" && typeof args[2] === "string" && args[2].includes('"commitGuard":"disable"'), + ); + expect(mergeCall).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/sandbox/agents/claude.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `claude.ts`** + +```ts +// src/sandbox/agents/claude.ts +import type { + AgentAdapter, AgentOutput, ConfigureOpts, PhaseArtifactPaths, PhaseKind, + PhaseScriptOpts, PhaseUsage, ResearchResult, ReviewOutput, RunnableSandbox, +} from "./types.js"; +import { agentOutputSchema, reviewOutputSchema } from "./types.js"; +import { installSkillsToAgentsDir } from "./shared.js"; +import { ARTHUR_TRACER_PY_BASE64 } from "../arthur-tracer.js"; + +const ARTHUR_HOOK_EVENTS: ReadonlyArray = [ + ["UserPromptSubmit", "user_prompt_submit"], + ["PreToolUse", "pre_tool"], + ["PostToolUse", "post_tool"], + ["PostToolUseFailure", "post_tool_failure"], + ["Stop", "stop"], +]; + +export class ClaudeAgentAdapter implements AgentAdapter { + readonly kind = "claude" as const; + + async install(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("npm", ["install", "-g", "@anthropic-ai/claude-code"]); + // Skip interactive onboarding + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, + ]); + } + + async configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise { + if (!opts.anthropicApiKey && !opts.claudeCodeOauthToken) { + throw new Error("ClaudeAgentAdapter.configure requires anthropicApiKey or claudeCodeOauthToken"); + } + const envLines: string[] = []; + if (opts.claudeCodeOauthToken) { + envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${shellQuote(opts.claudeCodeOauthToken)}`); + } else if (opts.anthropicApiKey) { + envLines.push(`export ANTHROPIC_API_KEY=${shellQuote(opts.anthropicApiKey)}`); + } + await sandbox.writeFiles([ + { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, + ]); + await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); + + // Skills: install into ~/.agents/skills, then symlink ~/.claude/skills → ~/.agents/skills + await installSkillsToAgentsDir(sandbox); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude && rm -rf $HOME/.claude/skills && ln -s $HOME/.agents/skills $HOME/.claude/skills", + ]); + + // Arthur tracer (no-op without config) + if (opts.arthur) { + await this.installArthurTracer(sandbox, opts.arthur); + } + } + + async setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise { + // 1) Drop the guard script (idempotent) + await sandbox.runCommand("bash", [ + "-c", + [ + "mkdir -p ~/.claude", + "cat > ~/.claude/commit-guard.sh << 'SCRIPT'", + "#!/bin/bash", + "input=$(cat)", + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + " exit 2", + "fi", + "SCRIPT", + "chmod +x ~/.claude/commit-guard.sh", + ].join("\n"), + ]); + + // 2) Toggle the Stop hook entry via merge-aware settings.json writer + await this.mergeSettings(sandbox, { commitGuard: enabled ? "enable" : "disable" }); + } + + buildPhaseScript(opts: PhaseScriptOpts): string { + const { paths, jsonSchema, model, phase } = opts; + let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions --output-format json`; + if (jsonSchema) { + const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); + claudeFlags += ` --json-schema '${escapedSchema}'`; + } + return `#!/bin/bash + +# --- Cleanup stale files from prior runs --- +rm -f ${paths.sentinel} ${paths.stdout} ${paths.stderr} + +# --- Source auth env vars --- +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +# --- Phase: ${phase} --- +cat ${paths.input} | claude \\ + ${claudeFlags} \\ + > ${paths.stdout} 2>${paths.stderr}; echo $? > /tmp/${phase}-exit-code || true + +# --- Cleanup --- +cd /vercel/sandbox +rm -rf .claude/ +git checkout -- .claude/ 2>/dev/null || true + +# --- Signal completion --- +touch ${paths.sentinel} +`; + } + + artifactPaths(phase: PhaseKind): PhaseArtifactPaths { + return { + wrapper: `/tmp/${phase}-wrapper.sh`, + input: `/tmp/${phase}-requirements.md`, + stdout: `/tmp/${phase}-stdout.txt`, + stderr: `/tmp/${phase}-stderr.txt`, + sentinel: `/tmp/${phase}-done`, + structuredOutput: null, + }; + } + + parseAgentOutput(raw: string, _structured: string | null): AgentOutput { + if (!raw.trim()) return { result: "failed", error: "Agent produced no output" }; + + try { + const direct = agentOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch { /* not direct JSON */ } + + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + if (event.type === "result") { + if (event.structured_output != null) { + const parsed = agentOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + if (typeof event.result === "string") { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(event.result)); + if (parsed.success) return parsed.data; + } catch { /* not JSON */ } + } + if (event.subtype === "success" && !event.is_error) { + return { + result: "implemented", + summary: typeof event.result === "string" ? event.result.trim().slice(0, 500) : undefined, + }; + } + return { + result: "failed", + error: typeof event.result === "string" ? event.result.trim().slice(0, 500) : "Agent returned non-structured result", + }; + } + const direct = agentOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch { /* try next line */ } + } + + const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); + for (const [candidate] of objects) { + try { + const result = agentOutputSchema.safeParse(JSON.parse(candidate)); + if (result.success) return result.data; + } catch { /* try next */ } + } + + return { result: "failed", error: `Agent output was not structured JSON. Output starts with: ${raw.slice(0, 500)}` }; + } + + parseReviewOutput(raw: string, _structured: string | null): ReviewOutput { + if (!raw.trim()) { + return { result: "failed", feedback: "", issues: [], error: "Review agent produced no output" }; + } + try { + const direct = reviewOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch { /* not direct JSON */ } + + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + if (event.type === "result" && event.structured_output != null) { + const parsed = reviewOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + const direct = reviewOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch { /* try next */ } + } + + const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); + for (const [candidate] of objects) { + try { + const result = reviewOutputSchema.safeParse(JSON.parse(candidate)); + if (result.success) return result.data; + } catch { /* try next */ } + } + + return { + result: "failed", feedback: "", issues: [], + error: `Review output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, + }; + } + + parseResearchStatus(raw: string, _structured: string | null): ResearchResult { + const text = unwrapResearchEnvelope(raw); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim() ?? ""; + const m = line.match(/^STATUS:\s*([a-z_]+)/i); + if (!m) continue; + const status = m[1].toLowerCase(); + if (status === "completed" || status === "clarification_needed" || status === "failed") { + return { status, body: lines.slice(i + 1).join("\n").trim() }; + } + } + return { status: "failed", body: text }; + } + + extractUsage(raw: string, _structured: string | null): PhaseUsage | null { + if (!raw.trim()) return null; + const envelope = findResultEnvelope(raw); + if (!envelope) return null; + const cost = + typeof envelope.cost_usd === "number" ? envelope.cost_usd + : typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd + : null; + if (cost === null) return null; + return { + cost_usd: cost, + tokens: null, + duration_ms: typeof envelope.duration_ms === "number" ? envelope.duration_ms : 0, + duration_api_ms: typeof envelope.duration_api_ms === "number" ? envelope.duration_api_ms : 0, + num_turns: typeof envelope.num_turns === "number" ? envelope.num_turns : 0, + }; + } + + // --- private --- + + private async installArthurTracer( + sandbox: RunnableSandbox, + arthur: NonNullable, + ): Promise { + const { logger } = await import("../../lib/logger.js"); + logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId, agent: this.kind }, "agent_install_arthur_started"); + + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", + ]); + if (pip.exitCode !== 0) { + logger.warn({}, "arthur_pip_install_failed"); + return; + } + + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([{ path: "/tmp/arthur-tracer.py", content: tracerBytes }]); + const mvTracer = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py", + ]); + if (mvTracer.exitCode !== 0) { + logger.warn({}, "arthur_tracer_install_failed"); + return; + } + + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, + null, 2, + ); + await sandbox.writeFiles([{ path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json", + ]); + + await this.mergeSettings(sandbox, { arthur: "install" }); + logger.info({ agent: this.kind }, "agent_install_arthur_complete"); + } + + /** Merge-aware writer for ~/.claude/settings.json. */ + private async mergeSettings( + sandbox: RunnableSandbox, + opts: { commitGuard?: "enable" | "disable"; arthur?: "install" }, + ): Promise { + const arthurEvents = JSON.stringify(ARTHUR_HOOK_EVENTS); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const arthurEvents = ${arthurEvents}; + const home = process.env.HOME; + const settingsPath = path.join(home, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsertHook = (event, matcher, command) => { + const existing = s.hooks[event] || []; + const has = existing.some(e => (e && Array.isArray(e.hooks) ? e.hooks : []).some(h => h && h.command === command)); + if (!has) existing.push({ matcher, hooks: [{ type: 'command', command }] }); + s.hooks[event] = existing; + }; + const removeHook = (event, predicate) => { + const existing = s.hooks[event] || []; + s.hooks[event] = existing + .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !predicate(h.command || '')) })) + .filter(e => (e.hooks || []).length > 0); + }; + + if (opts.commitGuard === 'enable') upsertHook('Stop', '', 'bash ~/.claude/commit-guard.sh'); + else if (opts.commitGuard === 'disable') removeHook('Stop', c => c.includes('commit-guard.sh')); + + if (opts.arthur === 'install') { + for (const [event, arg] of arthurEvents) { + upsertHook(event, '', 'python3 "$HOME/.claude/hooks/claude_code_tracer.py" ' + arg); + } + } + fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); + `; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); + } +} + +// --- module-private helpers --- + +function shellQuote(val: string): string { + return `'${val.replace(/'/g, "'\\''")}'`; +} + +function findResultEnvelope(raw: string): Record | null { + try { + const obj = JSON.parse(raw); + if (obj && typeof obj === "object" && (obj as any).type === "result") return obj as Record; + } catch { /* not single JSON */ } + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const obj = JSON.parse(lines[i]); + if (obj && typeof obj === "object" && (obj as any).type === "result") return obj as Record; + } catch { /* try next */ } + } + return null; +} + +function unwrapResearchEnvelope(raw: string): string { + if (!raw.trim()) return raw; + const env = findResultEnvelope(raw); + if (!env) return raw; + return typeof env.result === "string" ? env.result : raw; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/sandbox/agents/claude.test.ts` +Expected: PASS. + +--- + +### Task 4: Implement `agents/index.ts` — adapter factory + +**Files:** +- Create: `src/sandbox/agents/index.ts` +- Create: `src/sandbox/agents/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/sandbox/agents/index.test.ts +import { describe, it, expect } from "vitest"; +import { createAgentAdapter } from "./index.js"; + +describe("createAgentAdapter", () => { + it("returns ClaudeAgentAdapter for kind=claude", () => { + const a = createAgentAdapter("claude"); + expect(a.kind).toBe("claude"); + }); + + it("throws for unknown kinds (forces exhaustive switch updates)", () => { + // @ts-expect-error — runtime guard + expect(() => createAgentAdapter("bogus")).toThrow(); + }); +}); +``` + +Note: the `kind=codex` case is added in Task 14 once `CodexAgentAdapter` exists. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/agents/index.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the factory** + +```ts +// src/sandbox/agents/index.ts +import { ClaudeAgentAdapter } from "./claude.js"; +import type { AgentAdapter } from "./types.js"; + +export type AgentKind = "claude" | "codex"; + +export function createAgentAdapter(kind: AgentKind): AgentAdapter { + switch (kind) { + case "claude": return new ClaudeAgentAdapter(); + case "codex": + throw new Error("Codex adapter not yet wired (see Task 14)"); + default: { + const _exhaustive: never = kind; + throw new Error(`Unknown AGENT_KIND: ${_exhaustive}`); + } + } +} + +export type { AgentAdapter } from "./types.js"; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest run src/sandbox/agents/index.test.ts` +Expected: PASS. + +--- + +### Task 5: Add `collectPhase` helper to `poll-agent.ts` + +**Files:** +- Modify: `src/sandbox/poll-agent.ts` +- Modify: `src/sandbox/poll-agent.test.ts` + +- [ ] **Step 1: Write the failing test (append to `poll-agent.test.ts`)** + +```ts +import { collectPhase } from "./poll-agent.js"; + +describe("collectPhase", () => { + it("returns raw + structured when structuredOutput is set", async () => { + const stdoutText = "ndjson body"; + const structuredText = '{"result":"implemented"}'; + // Mock @vercel/sandbox so Sandbox.get returns a fake with cat + vi.doMock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn().mockResolvedValue({ + runCommand: vi.fn().mockImplementation(async (_, args) => { + const file = args[0]; + const out = + file.includes("stdout") ? stdoutText : + file.includes("structured") || file.endsWith("result.json") ? structuredText : + ""; + return { stdout: async () => out }; + }), + }), + }, + })); + + const result = await collectPhase("sbx-1", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: "/tmp/impl-result.json", + }); + expect(result.raw).toBe(stdoutText); + expect(result.structured).toBe(structuredText); + }); + + it("returns structured=null when paths.structuredOutput is null", async () => { + vi.doMock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn().mockResolvedValue({ + runCommand: vi.fn().mockResolvedValue({ stdout: async () => "raw text" }), + }), + }, + })); + const r = await collectPhase("sbx-1", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: null, + }); + expect(r.structured).toBeNull(); + expect(r.raw).toBe("raw text"); + }); + + it("falls back to stderr when stdout is empty", async () => { + vi.doMock("@vercel/sandbox", () => ({ + Sandbox: { + get: vi.fn().mockResolvedValue({ + runCommand: vi.fn().mockImplementation(async (_, args) => ({ + stdout: async () => args[0].includes("stdout") ? "" : "stderr text", + })), + }), + }, + })); + const r = await collectPhase("sbx-1", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: null, + }); + expect(r.raw).toBe("stderr text"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/poll-agent.test.ts` +Expected: FAIL — `collectPhase` not exported. + +- [ ] **Step 3: Implement `collectPhase` in `poll-agent.ts`** + +Append after `collectPhaseOutput`: + +```ts +/** + * Collect raw + (optional) structured phase output. Replaces collectPhaseOutput + * in adapter-aware code paths. + */ +export async function collectPhase( + sandboxId: string, + paths: { stdout: string; stderr: string; structuredOutput: string | null }, +): Promise<{ raw: string; structured: string | null }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + const stdoutResult = await sandbox.runCommand("cat", [paths.stdout]); + const stdoutText = (await stdoutResult.stdout()).trim(); + const stderrResult = await sandbox.runCommand("cat", [paths.stderr]); + const stderrText = (await stderrResult.stdout()).trim(); + const raw = stdoutText || stderrText; + + let structured: string | null = null; + if (paths.structuredOutput) { + const r = await sandbox.runCommand("cat", [paths.structuredOutput]); + const text = (await r.stdout()).trim(); + structured = text || null; + } + return { raw, structured }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm vitest run src/sandbox/poll-agent.test.ts` +Expected: PASS. + +--- + +### Task 6: Slim `SandboxManager` + +**Files:** +- Modify: `src/sandbox/manager.ts` +- Modify: `src/sandbox/manager.test.ts` + +The manager keeps responsibility for the sandbox lifecycle (create, clone, identity, merge, push prep) but delegates anything agent-specific to an injected `AgentAdapter`. After this task: no `GLOBAL_SKILLS`, no `installArthurTracer`, no `configureStopHookInSandbox` exports. + +- [ ] **Step 1: Migrate `manager.test.ts` to the new signature** + +Keep the lifecycle assertions that still belong to the manager (Sandbox.create source, git identity, optional merge-base, pre-agent-sha capture). Move the agent-specific assertions (auth `agent-env.sh`, commit-guard, Arthur, skills install) — they belong to `claude.test.ts` (already added in Task 3) and aren't valid against the slim manager any more. + +Rewrite `src/sandbox/manager.test.ts` so it injects a fake adapter and asserts both the lifecycle and the delegation: + +```ts +// src/sandbox/manager.test.ts (rewritten) +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockRunCommand = vi.fn(); +const mockWriteFiles = vi.fn(); +const mockStop = vi.fn(); +const mockStdout = vi.fn(); + +vi.mock("@vercel/sandbox", () => ({ + Sandbox: { + create: vi.fn(() => ({ + sandboxId: "sbx-test-123", + runCommand: mockRunCommand, + writeFiles: mockWriteFiles, + stop: mockStop, + })), + }, +})); + +import { SandboxManager } from "./manager.js"; +import type { AgentAdapter, ConfigureOpts } from "./agents/types.js"; + +const makeFakeAgent = (): AgentAdapter & { calls: any[] } => { + const calls: any[] = []; + return { + kind: "claude", + install: vi.fn(async () => { calls.push({ op: "install" }); }), + configure: vi.fn(async (_, opts: ConfigureOpts) => { calls.push({ op: "configure", opts }); }), + setCommitGuard: vi.fn(async (_s, enabled) => { calls.push({ op: "guard", enabled }); }), + buildPhaseScript: () => "#!/bin/bash\necho noop", + artifactPaths: () => ({ wrapper: "", input: "", stdout: "", stderr: "", sentinel: "", structuredOutput: null }), + parseAgentOutput: () => ({ result: "implemented" }), + parseReviewOutput: () => ({ result: "approved", feedback: "", issues: [] }), + parseResearchStatus: () => ({ status: "completed", body: "" }), + extractUsage: () => null, + calls, + } as any; +}; + +describe("SandboxManager.provision", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRunCommand.mockResolvedValue({ exitCode: 0, stdout: mockStdout }); + mockStdout.mockResolvedValue(""); + mockWriteFiles.mockResolvedValue(undefined); + }); + + const baseConfig = { + kind: "github" as const, + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + jobTimeoutMs: 1_800_000, + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + }; + + it("creates the sandbox with a git source pointed at the branch", async () => { + const { Sandbox } = await import("@vercel/sandbox"); + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); + expect(Sandbox.create).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ type: "git", revision: "feat/test-branch" }), + runtime: "node24", + }), + ); + }); + + it("sets git identity to commitAuthor / commitEmail", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); + const idCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("git config user.name"), + ); + expect(idCall).toBeDefined(); + expect(idCall![1][1]).toContain("ai-workflow-blazity"); + expect(idCall![1][1]).toContain("bot@blazity.com"); + }); + + it("captures pre-agent HEAD SHA for the push step", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); + const shaCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("/tmp/.pre-agent-sha"), + ); + expect(shaCall).toBeDefined(); + }); + + it("calls agent.install then agent.configure with the supplied opts", async () => { + const agent = makeFakeAgent(); + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", agent, { + anthropicApiKey: "sk-ant-test", + model: "claude-opus-4-6", + }); + const ops = (agent as any).calls.map((c: any) => c.op); + expect(ops).toEqual(["install", "configure"]); + expect((agent as any).calls[1].opts).toEqual( + expect.objectContaining({ anthropicApiKey: "sk-ant-test", model: "claude-opus-4-6" }), + ); + }); + + it("fetches and merges mergeBase when supplied", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }, "main"); + const fetchCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("git fetch"), + ); + expect(fetchCall).toBeDefined(); + expect(fetchCall![1][1]).toContain("main"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/manager.test.ts` +Expected: FAIL — new signature not in place. + +- [ ] **Step 3: Rewrite `manager.ts` to thin orchestrator** + +Replace the entire file. The new manager has only repo provisioning duties: + +```ts +// src/sandbox/manager.ts +import type { Sandbox as SandboxType } from "@vercel/sandbox"; +import { getSandboxCredentials } from "./credentials.js"; +import type { AgentAdapter, ConfigureOpts } from "./agents/types.js"; + +export interface SandboxConfig { + kind: "github" | "gitlab"; + token: string; + repoPath: string; + host: string; + jobTimeoutMs: number; + commitAuthor: string; + commitEmail: string; +} + +/** Build clone/push URLs for the configured VCS. Unchanged from previous behaviour. */ +export function buildVcsUrls(config: { kind: "github" | "gitlab"; token: string; repoPath: string; host: string }) { + const host = config.host.replace(/\/+$/, ""); + const scheme = host.match(/^https?:\/\//)?.[0] ?? "https://"; + const hostNoScheme = host.replace(/^https?:\/\//, ""); + const authUser = config.kind === "gitlab" ? "oauth2" : "x-access-token"; + return { + cloneUrl: `${host}/${config.repoPath}.git`, + authUrl: `${scheme}${authUser}:${config.token}@${hostNoScheme}/${config.repoPath}.git`, + authUser, + }; +} + +type SandboxInstance = Awaited>; + +export class SandboxManager { + constructor(private config: SandboxConfig) {} + + async provision( + branch: string, + agent: AgentAdapter, + configureOpts: ConfigureOpts, + mergeBase?: string, + ): Promise { + const { Sandbox } = await import("@vercel/sandbox"); + const urls = buildVcsUrls(this.config); + + const sandbox = await Sandbox.create({ + ...getSandboxCredentials(), + source: { + type: "git", + url: urls.cloneUrl, + username: urls.authUser, + password: this.config.token, + revision: branch, + }, + runtime: "node24", + timeout: this.config.jobTimeoutMs, + }); + + // Strip auth from origin + await sandbox.runCommand("git", ["remote", "set-url", "origin", urls.cloneUrl]); + // Re-create the local branch (clone is detached HEAD on a revision) + await sandbox.runCommand("git", ["checkout", "-B", branch]); + // Identity + await sandbox.runCommand("bash", [ + "-c", + `git config user.name "${this.config.commitAuthor}" && git config user.email "${this.config.commitEmail}"`, + ]); + + if (mergeBase) { + const repoUrl = urls.authUrl; + await sandbox.runCommand("bash", ["-c", `git fetch "${repoUrl}" ${mergeBase} 2>&1`]); + await sandbox.runCommand("bash", ["-c", `git branch ${mergeBase} FETCH_HEAD 2>/dev/null || true`]); + const merge = await sandbox.runCommand("bash", ["-c", `git merge FETCH_HEAD --no-edit 2>&1`]); + if (merge.exitCode !== 0) { + const out = (await merge.stdout()).trim(); + const { logger } = await import("../lib/logger.js"); + logger.warn({ mergeBase, exitCode: merge.exitCode, output: out.slice(0, 500) }, "merge_conflicts_during_provision"); + } + } + + // Pre-agent SHA so push step can detect commits + await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD > /tmp/.pre-agent-sha"]); + + // --- Agent-specific work delegated to the adapter --- + await agent.install(sandbox); + await agent.configure(sandbox, configureOpts); + + return sandbox; + } + + async teardown(sandbox: SandboxInstance): Promise { + try { await sandbox.stop(); } catch { /* non-critical */ } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest run src/sandbox/manager.test.ts` +Expected: PASS. + +--- + +### Task 7: Update `src/workflows/agent.ts` to use the adapter + +**Files:** +- Modify: `src/workflows/agent.ts` + +Threaded changes: +- `provisionSandbox` builds the adapter, returns `{ sandboxId, agentKind }` (persist `agentKind` so downstream steps reconstruct via `createAgentAdapter(agentKind)`). +- `configureStopHook` step → `setCommitGuardStep(sandboxId, agentKind, enabled)`. +- `writeAndStartPhase` callers swap to adapter paths + adapter `buildPhaseScript`. +- `collectPhaseOutput` → `collectPhase`. +- `extractUsage`, `parseResearchStatus`, `parseAgentOutput`, `parseReviewOutput`, `unwrapResearchText` calls move to `agent.X(...)`. +- The `import buildPhaseScript from "../sandbox/wrapper-script"` line is deleted. +- The `import { ... } from "../sandbox/agent-runner"` line is deleted. + +- [ ] **Step 1: Replace `provisionSandbox` and `configureStopHook` step bodies** + +```ts +// near top — keep type imports updated +import type { AgentOutput, ReviewOutput, PhaseUsage } from "../sandbox/agents/types.js"; +import type { AgentKind } from "../sandbox/agents/index.js"; + +async function provisionSandbox( + branchName: string, + arthurTaskId: string | null, + mergeBase?: string, +): Promise<{ sandboxId: string; agentKind: AgentKind }> { + "use step"; + const { env, getVcsConfig } = await import("../../env.js"); + const { SandboxManager } = await import("../sandbox/manager.js"); + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + const vcs = getVcsConfig(); + + if (vcs.kind === "gitlab" && /^\d+$/.test(vcs.repoPath)) { + throw new Error( + `GITLAB_PROJECT_ID must be a namespace/project path (e.g. "group/repo"), ` + + `not a numeric project ID ("${vcs.repoPath}").`, + ); + } + + const arthur = + env.GENAI_ENGINE_API_KEY && env.GENAI_ENGINE_TRACE_ENDPOINT && arthurTaskId + ? { apiKey: env.GENAI_ENGINE_API_KEY, taskId: arthurTaskId, endpoint: env.GENAI_ENGINE_TRACE_ENDPOINT } + : undefined; + + const agentKind: AgentKind = env.AGENT_KIND; // Will be set by Task 11; default 'claude' until then + const agent = createAgentAdapter(agentKind); + + const manager = new SandboxManager({ + kind: vcs.kind, + token: vcs.token, + repoPath: vcs.repoPath, + host: vcs.host, + jobTimeoutMs: env.JOB_TIMEOUT_MS, + commitAuthor: env.COMMIT_AUTHOR, + commitEmail: env.COMMIT_EMAIL, + }); + + const sandbox = await manager.provision(branchName, agent, { + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + codexApiKey: env.CODEX_API_KEY, // unset until Task 11 + codexChatGptOauthToken: env.CODEX_CHATGPT_OAUTH_TOKEN, // unset until Task 11 + model: agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL, + arthur, + }, mergeBase); + + return { sandboxId: sandbox.sandboxId, agentKind }; +} +provisionSandbox.maxRetries = 0; + +async function setCommitGuardStep(sandboxId: string, agentKind: AgentKind, enabled: boolean): Promise { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const { getSandboxCredentials } = await import("../sandbox/credentials.js"); + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + const agent = createAgentAdapter(agentKind); + await agent.setCommitGuard(sandbox, enabled); +} +``` + +Delete the old `configureStopHook` step. + +- [ ] **Step 2: Replace per-phase wiring inside `agentWorkflow`** + +Update the imports at the top of the workflow: + +```ts +const { collectPhase, pushFromSandbox, fixAndRetryPush, teardownSandbox } = + await import("../sandbox/poll-agent.js"); +const { formatUsageReport } = await import("../sandbox/usage.js"); +const { createAgentAdapter } = await import("../sandbox/agents/index.js"); +const { AGENT_SCHEMA, REVIEW_SCHEMA } = await import("../sandbox/agents/types.js"); +``` + +Delete the old imports: +- `buildPhaseScript` from `../sandbox/wrapper-script.js` +- `parseResearchStatus, parseAgentOutput, parseReviewOutput` from `../sandbox/agent-runner.js` +- `extractUsage, unwrapResearchText` from `../sandbox/usage.js` + +Inside `agentWorkflow`, after `provisionSandbox`: + +```ts +const { sandboxId, agentKind } = await provisionSandbox(branchName, arthurTaskId, mergeBase); +await registerTicketSandbox(ticket.identifier, sandboxId); + +const agent = createAgentAdapter(agentKind); // local handle for parsers + buildPhaseScript +``` + +Each phase block changes from "build script with `buildPhaseScript(...)`" to "build script with `agent.buildPhaseScript({ phase, model, paths, jsonSchema })`" and uses `agent.artifactPaths(phase)`. Example for research: + +```ts +// ========== PHASE 1: Research & Plan ========== +await setCommitGuardStep(sandboxId, agentKind, false); + +const researchPaths = agent.artifactPaths("research"); +const researchInput = assembleResearchPlanContext({ /* unchanged */ }); +const researchScript = agent.buildPhaseScript({ + phase: "research", + model: agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL, + paths: researchPaths, +}); + +await writeAndStartPhase( + sandboxId, + researchPaths.input, researchInput, + researchPaths.wrapper, researchScript, +); + +const researchDone = await pollUntilDone(sandboxId, researchPaths.sentinel, 20); +if (!researchDone) { /* same backlog/notify path as before */ } + +const { raw: researchRaw, structured: researchStructured } = + await collectPhase(sandboxId, researchPaths); +phaseUsages["Research"] = agent.extractUsage(researchRaw, researchStructured); +const research = agent.parseResearchStatus(researchRaw, researchStructured); +``` + +Implementation phase block (full replacement): + +```ts +// ========== PHASE 2: Implementation ========== +await setCommitGuardStep(sandboxId, agentKind, true); + +const implPaths = agent.artifactPaths("impl"); +const implInput = assembleImplementationContext({ + ticket: ticketData, + prompt: prompts.implement, + researchPlanMarkdown, + attachments: downloadedAttachments, +}); +const implScript = agent.buildPhaseScript({ + phase: "impl", + model: agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL, + paths: implPaths, + jsonSchema: AGENT_SCHEMA, +}); + +await writeAndStartPhase( + sandboxId, + implPaths.input, implInput, + implPaths.wrapper, implScript, +); + +const implDone = await pollUntilDone(sandboxId, implPaths.sentinel, 35); +let implOutput: AgentOutput; +if (implDone) { + const { raw, structured } = await collectPhase(sandboxId, implPaths); + phaseUsages["Impl"] = agent.extractUsage(raw, structured); + implOutput = agent.parseAgentOutput(raw, structured); +} else { + implOutput = { result: "failed", error: "Implementation phase timed out" }; +} +// (existing branches on implOutput.result are untouched) +``` + +For the disabled review block (lines around `// ========== PHASE 3: Review ==========`), update its structure to mirror the impl block (`agent.artifactPaths("review")`, `agent.buildPhaseScript({ phase: "review", paths, jsonSchema: REVIEW_SCHEMA })`, `agent.parseReviewOutput(raw, structured)`) so re-enabling later is a single comment toggle. + +Replace `setCommitGuardStep(sandboxId, agentKind, true)` everywhere `await configureStopHook(sandboxId, true)` appeared. + +- [ ] **Step 3: Update the usage suffix call site** + +`formatUsageReport` will gain an optional `priceLookup` argument in Task 8. For Phase 1 the workflow keeps the existing single-arg call: `formatUsageReport(phaseUsages)`. + +- [ ] **Step 4: Run the workflow's existing tests** + +Run: `pnpm typecheck && pnpm vitest run src/workflows/prompts-step.test.ts` +Expected: PASS — `prompts-step.test.ts` is untouched and `agent.ts` only changed wiring. + +--- + +### Task 8: Update `usage.ts` for the new PhaseUsage shape + +**Files:** +- Modify: `src/sandbox/usage.ts` +- Modify: `src/sandbox/usage.test.ts` + +The old `extractUsage` and `unwrapResearchText` move into adapters (Task 3), so this file shrinks to `formatUsageReport`. The `PhaseUsage` interface re-exports from `agents/types.ts` for backward-compat imports. + +- [ ] **Step 1: Replace `usage.ts`** + +```ts +// src/sandbox/usage.ts +import type { PhaseUsage } from "./agents/types.js"; +import type { TokenPrice } from "./agents/pricing.js"; // forward-declare; Task 12 creates the file + +export type { PhaseUsage } from "./agents/types.js"; + +export type PriceLookup = (model: string) => TokenPrice | null; + +/** + * Slack-friendly usage line. Computes Codex costs from tokens when a price + * is available; falls back to "cost unknown" for Codex without pricing. + * + * For each phase: + * - cost_usd != null → use it directly (Claude path) + * - tokens != null + priceLookup yields a price → compute cost + * - else → tokens-only, marked "cost unknown" + */ +export function formatUsageReport( + phases: Record, + priceLookup?: PriceLookup, + model?: string, +): string { + const parts: string[] = []; + let totalCost = 0; + let anyUnknown = false; + + for (const [name, usage] of Object.entries(phases)) { + if (!usage) { parts.push(`${name}: n/a`); continue; } + const mins = Math.round(usage.duration_ms / 60_000); + let costLabel: string; + if (usage.cost_usd != null) { + totalCost += usage.cost_usd; + costLabel = `$${usage.cost_usd.toFixed(2)}`; + } else if (usage.tokens && priceLookup && model) { + const price = priceLookup(model); + if (price) { + const cost = usage.tokens.input * price.input + + usage.tokens.cached_input * price.cached_input + + usage.tokens.output * price.output; + totalCost += cost; + costLabel = `$${cost.toFixed(2)}`; + } else { + anyUnknown = true; + costLabel = `${usage.tokens.input}/${usage.tokens.output} tok (cost unknown)`; + } + } else if (usage.tokens) { + anyUnknown = true; + costLabel = `${usage.tokens.input}/${usage.tokens.output} tok (cost unknown)`; + } else { + anyUnknown = true; + costLabel = "cost unknown"; + } + parts.push(`${name}: ${costLabel} (${mins}m)`); + } + + const total = anyUnknown ? `$${totalCost.toFixed(2)}+ total` : `$${totalCost.toFixed(2)} total`; + return `Usage: ${total} | ${parts.join(" | ")}`; +} +``` + +- [ ] **Step 2: Move `extractUsage` and `unwrapResearchText` tests to `agents/claude.test.ts`** (already done in Task 3). + Replace `src/sandbox/usage.test.ts` with `formatUsageReport`-only coverage: + +```ts +// src/sandbox/usage.test.ts +import { describe, it, expect } from "vitest"; +import { formatUsageReport, type PhaseUsage } from "./usage.js"; + +const u = (over: Partial = {}): PhaseUsage => ({ + cost_usd: null, tokens: null, duration_ms: 60_000, duration_api_ms: 30_000, num_turns: 1, ...over, +}); + +describe("formatUsageReport", () => { + it("uses cost_usd when present", () => { + const out = formatUsageReport({ Impl: u({ cost_usd: 1.23 }) }); + expect(out).toContain("$1.23"); + expect(out).toContain("$1.23 total"); + }); + + it("computes cost from tokens + priceLookup when cost_usd is null", () => { + const out = formatUsageReport( + { Impl: u({ tokens: { input: 1000, cached_input: 0, output: 500 } }) }, + () => ({ input: 0.000003, cached_input: 0, output: 0.000015 }), + "gpt-5-codex", + ); + expect(out).toMatch(/\$0\.0[01]/); + expect(out).not.toContain("cost unknown"); + }); + + it("falls back to tokens-only when no price and tokens are present", () => { + const out = formatUsageReport( + { Impl: u({ tokens: { input: 100, cached_input: 0, output: 50 } }) }, + () => null, + "unknown-model", + ); + expect(out).toContain("100/50 tok (cost unknown)"); + expect(out).toContain("+ total"); + }); + + it("shows n/a for null phases", () => { + const out = formatUsageReport({ Impl: null }); + expect(out).toContain("Impl: n/a"); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Note: `TokenPrice` import will fail until Task 12. Stub it temporarily by adding this line to the top of `usage.ts` (and removing it during Task 12): + +```ts +// remove during Task 12 once agents/pricing.ts exists +type TokenPrice = { input: number; cached_input: number; output: number }; +``` + +Then drop the `import type { TokenPrice } from "./agents/pricing.js"` line. + +Run: `pnpm vitest run src/sandbox/usage.test.ts` +Expected: PASS. + +--- + +### Task 9: Delete `wrapper-script.ts` + tests + +**Files:** +- Delete: `src/sandbox/wrapper-script.ts` +- Delete: `src/sandbox/wrapper-script.test.ts` + +- [ ] **Step 1: Verify no remaining imports** + +Run: `grep -R "wrapper-script" src/ docs/` +Expected: zero matches in `src/` (the spec file is fine). + +- [ ] **Step 2: Delete the files** + +```bash +rm src/sandbox/wrapper-script.ts src/sandbox/wrapper-script.test.ts +``` + +- [ ] **Step 3: Run typecheck + full unit suite** + +Run: `pnpm typecheck && pnpm test` +Expected: PASS. + +--- + +### Task 10: Delete `agent-runner.ts` + tests + +**Files:** +- Delete: `src/sandbox/agent-runner.ts` +- Delete: `src/sandbox/agent-runner.test.ts` + +The schemas and parsers moved to `agents/types.ts` and `agents/claude.ts`; the test cases moved to `agents/claude.test.ts`. Nothing should still import from this file. + +- [ ] **Step 1: Verify no remaining imports** + +Run: `grep -R "from .*sandbox/agent-runner" src/` +Expected: zero matches. + +- [ ] **Step 2: Delete the files** + +```bash +rm src/sandbox/agent-runner.ts src/sandbox/agent-runner.test.ts +``` + +- [ ] **Step 3: Final Phase 1 build** + +Run: `pnpm typecheck && pnpm test` +Expected: PASS — every existing unit test still green; the suite now exercises the adapter abstraction for Claude. + +- [ ] **Step 4: Commit Phase 1** + +```bash +git add src/sandbox/agents src/sandbox/manager.ts src/sandbox/manager.test.ts \ + src/sandbox/poll-agent.ts src/sandbox/poll-agent.test.ts \ + src/sandbox/usage.ts src/sandbox/usage.test.ts \ + src/workflows/agent.ts +git rm src/sandbox/wrapper-script.ts src/sandbox/wrapper-script.test.ts \ + src/sandbox/agent-runner.ts src/sandbox/agent-runner.test.ts +git commit -m "refactor(sandbox): extract Claude logic behind AgentAdapter interface" +``` + +--- + +## Phase 2 — Add the Codex adapter + +### Task 11: Add Codex env vars + cross-field validation + +**Files:** +- Modify: `env.ts` +- Modify: `.env.example` + +- [ ] **Step 1: Extend the schema in `env.ts`** + +Add to the `server` block (place after the existing `CLAUDE_MODEL` line): + +```ts +// Agent kind selection (claude | codex). Defaults to claude for back-compat. +AGENT_KIND: z.enum(["claude", "codex"]).default("claude"), + +// Codex auth — at least one required when AGENT_KIND=codex. +CODEX_API_KEY: z.string().min(1).optional(), +CODEX_CHATGPT_OAUTH_TOKEN: z.string().min(1).optional(), + +// Codex model selection. +CODEX_MODEL: z.string().default("gpt-5-codex"), + +// LiteLLM community-maintained pricing JSON. Operator overridable. +CODEX_PRICING_URL: z + .string() + .url() + .default("https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"), +CODEX_PRICING_TTL_MS: z.coerce.number().int().positive().default(3_600_000), +``` + +Inside the cross-field block at the bottom of `env.ts`, add the AGENT_KIND guards next to the VCS_KIND check: + +```ts +if (env.AGENT_KIND === "codex" && !env.CODEX_API_KEY && !env.CODEX_CHATGPT_OAUTH_TOKEN) { + throw new Error( + "Invalid environment variables:\n" + + " AGENT_KIND=codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN", + ); +} +if (env.AGENT_KIND === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + throw new Error( + "Invalid environment variables:\n" + + " AGENT_KIND=claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", + ); +} +``` + +- [ ] **Step 2: Update `.env.example`** + +Append (after the existing `CLAUDE_MODEL` block): + +```bash +# Agent — choose runtime (claude | codex). Defaults to claude. +AGENT_KIND=claude + +# Codex (only when AGENT_KIND=codex) +# CODEX_API_KEY= +# CODEX_CHATGPT_OAUTH_TOKEN= # alternative to CODEX_API_KEY +# CODEX_MODEL=gpt-5-codex +# CODEX_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json +# CODEX_PRICING_TTL_MS=3600000 +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +--- + +### Task 12: Implement `agents/pricing.ts` + +**Files:** +- Create: `src/sandbox/agents/pricing.ts` +- Create: `src/sandbox/agents/pricing.test.ts` +- Modify: `src/sandbox/usage.ts` (drop the local `TokenPrice` stub from Task 8 and import from pricing) + +- [ ] **Step 1: Write the failing test** + +```ts +// src/sandbox/agents/pricing.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const SAMPLE = { + "gpt-5-codex": { + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 0.0000007, + }, +}; + +describe("fetchModelPrice", () => { + beforeEach(() => { vi.resetModules(); }); + + it("normalises LiteLLM JSON to TokenPrice", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, json: async () => SAMPLE, + })); + const { fetchModelPrice } = await import("./pricing.js"); + const p = await fetchModelPrice("gpt-5-codex"); + expect(p).toEqual({ input: 0.000003, cached_input: 0.0000007, output: 0.000015 }); + }); + + it("returns null on miss", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + const { fetchModelPrice } = await import("./pricing.js"); + expect(await fetchModelPrice("unknown")).toBeNull(); + }); + + it("returns null on fetch failure", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + const { fetchModelPrice } = await import("./pricing.js"); + expect(await fetchModelPrice("any")).toBeNull(); + }); + + it("caches successful responses within TTL (one fetch for two calls)", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => SAMPLE }); + vi.stubGlobal("fetch", fetchMock); + const { fetchModelPrice } = await import("./pricing.js"); + await fetchModelPrice("gpt-5-codex"); + await fetchModelPrice("gpt-5-codex"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/agents/pricing.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `pricing.ts`** + +```ts +// src/sandbox/agents/pricing.ts +export interface TokenPrice { + input: number; + cached_input: number; + output: number; +} + +interface CacheEntry { + fetchedAt: number; + data: Record; +} +let cache: CacheEntry | null = null; + +interface LiteLLMEntry { + input_cost_per_token?: number; + output_cost_per_token?: number; + cache_read_input_token_cost?: number; +} + +async function loadAll(): Promise | null> { + const { env } = await import("../../../env.js"); + const ttl = env.CODEX_PRICING_TTL_MS; + if (cache && Date.now() - cache.fetchedAt < ttl) return cache.data; + + try { + const r = await fetch(env.CODEX_PRICING_URL); + if (!r.ok) return null; + const json = await r.json(); + const out: Record = {}; + for (const [name, entry] of Object.entries(json as Record)) { + if (typeof entry !== "object" || entry === null) continue; + const input = entry.input_cost_per_token; + const output = entry.output_cost_per_token; + if (typeof input !== "number" || typeof output !== "number") continue; + out[name] = { + input, + output, + cached_input: typeof entry.cache_read_input_token_cost === "number" + ? entry.cache_read_input_token_cost + : 0, + }; + } + cache = { fetchedAt: Date.now(), data: out }; + return out; + } catch { + return null; + } +} + +export async function fetchModelPrice(model: string): Promise { + const all = await loadAll(); + return all?.[model] ?? null; +} + +/** Test-only: clear the in-memory cache. */ +export function _resetPricingCache(): void { cache = null; } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/sandbox/agents/pricing.test.ts` +Expected: PASS. + +- [ ] **Step 5: Drop the local `TokenPrice` stub from `usage.ts`** + +In `src/sandbox/usage.ts`, replace the inline `type TokenPrice` with: + +```ts +import type { TokenPrice } from "./agents/pricing.js"; +export type { TokenPrice }; +``` + +Run: `pnpm typecheck && pnpm vitest run src/sandbox/usage.test.ts` +Expected: PASS. + +--- + +### Task 13: Implement `agents/codex.ts` — Codex adapter + +**Files:** +- Create: `src/sandbox/agents/codex.ts` +- Create: `src/sandbox/agents/codex.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// src/sandbox/agents/codex.test.ts +import { describe, it, expect, vi } from "vitest"; +import { CodexAgentAdapter } from "./codex.js"; + +const adapter = new CodexAgentAdapter(); + +describe("CodexAgentAdapter.parseAgentOutput", () => { + it("prefers structured JSON when valid", () => { + const structured = JSON.stringify({ result: "implemented", summary: "ok" }); + const out = adapter.parseAgentOutput("ignored ndjson", structured); + expect(out.result).toBe("implemented"); + expect(out.summary).toBe("ok"); + }); + + it("falls back to NDJSON item.completed when structured is missing", () => { + const ndjson = [ + JSON.stringify({ type: "thread.started", thread_id: "t" }), + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: '{"result":"implemented","summary":"foo"}' }, + }), + ].join("\n"); + const out = adapter.parseAgentOutput(ndjson, null); + expect(out.result).toBe("implemented"); + expect(out.summary).toBe("foo"); + }); + + it("returns failed when both sources are unparseable", () => { + expect(adapter.parseAgentOutput("not ndjson", null).result).toBe("failed"); + }); +}); + +describe("CodexAgentAdapter.parseResearchStatus", () => { + it("reads STATUS line from structured (free-form text)", () => { + const r = adapter.parseResearchStatus("ndjson irrelevant", "STATUS: completed\n\nbody"); + expect(r.status).toBe("completed"); + expect(r.body).toBe("body"); + }); + + it("falls back to last item.completed text when structured is null", () => { + const ndjson = [ + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "STATUS: failed\n\nreason" } }), + ].join("\n"); + const r = adapter.parseResearchStatus(ndjson, null); + expect(r.status).toBe("failed"); + }); +}); + +describe("CodexAgentAdapter.extractUsage", () => { + it("sums usage across multiple turn.completed events", () => { + const ndjson = [ + JSON.stringify({ type: "turn.completed", usage: { input_tokens: 100, output_tokens: 200, cached_input_tokens: 10 } }), + JSON.stringify({ type: "turn.completed", usage: { input_tokens: 50, output_tokens: 75, cached_input_tokens: 5 } }), + ].join("\n"); + const u = adapter.extractUsage(ndjson, null); + expect(u).toEqual({ + cost_usd: null, + tokens: { input: 150, cached_input: 15, output: 275 }, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 2, + }); + }); + + it("returns null when no turn.completed event is present", () => { + expect(adapter.extractUsage("\n", null)).toBeNull(); + }); +}); + +describe("CodexAgentAdapter.buildPhaseScript", () => { + it("research phase uses -o without --output-schema", () => { + const paths = adapter.artifactPaths("research"); + const s = adapter.buildPhaseScript({ phase: "research", model: "gpt-5-codex", paths }); + expect(s).toContain("codex exec"); + expect(s).toContain("--full-auto"); + expect(s).toContain("--skip-git-repo-check"); + expect(s).toContain("--json"); + expect(s).toContain("-o /tmp/research-result.json"); + expect(s).not.toContain("--output-schema"); + }); + + it("impl phase uses --output-schema with a heredoc", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ + phase: "impl", + model: "gpt-5-codex", + paths, + jsonSchema: '{"type":"object"}', + }); + expect(s).toContain("--output-schema /tmp/impl-schema.json"); + expect(s).toContain("'SCHEMA_EOF'"); + }); +}); + +describe("CodexAgentAdapter.artifactPaths", () => { + it("includes structuredOutput pointing at -o file", () => { + expect(adapter.artifactPaths("impl").structuredOutput).toBe("/tmp/impl-result.json"); + }); +}); + +describe("CodexAgentAdapter.setCommitGuard", () => { + it("upserts the Stop hook in ~/.codex/hooks.json when enabled", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandbox = { runCommand, writeFiles: vi.fn() } as any; + await adapter.setCommitGuard(sandbox, true); + const merge = runCommand.mock.calls.find(([cmd, args]) => + cmd === "node" && typeof args[2] === "string" && args[2].includes('"commitGuard":"enable"'), + ); + expect(merge).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/sandbox/agents/codex.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `codex.ts`** + +```ts +// src/sandbox/agents/codex.ts +import type { + AgentAdapter, AgentOutput, ConfigureOpts, PhaseArtifactPaths, PhaseKind, + PhaseScriptOpts, PhaseUsage, ResearchResult, ReviewOutput, RunnableSandbox, +} from "./types.js"; +import { agentOutputSchema, reviewOutputSchema } from "./types.js"; +import { installSkillsToAgentsDir } from "./shared.js"; +import { ARTHUR_TRACER_PY_BASE64 } from "../arthur-tracer.js"; + +const ARTHUR_HOOK_EVENTS: ReadonlyArray = [ + ["UserPromptSubmit", "user_prompt_submit"], + ["PreToolUse", "pre_tool"], + ["PostToolUse", "post_tool"], + ["Stop", "stop"], +]; + +export class CodexAgentAdapter implements AgentAdapter { + readonly kind = "codex" as const; + + async install(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("npm", ["install", "-g", "@openai/codex"]); + } + + async configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise { + if (!opts.codexApiKey && !opts.codexChatGptOauthToken) { + throw new Error("CodexAgentAdapter.configure requires codexApiKey or codexChatGptOauthToken"); + } + + // 1) auth env file + const envLines: string[] = []; + if (opts.codexApiKey) envLines.push(`export CODEX_API_KEY=${shellQuote(opts.codexApiKey)}`); + else if (opts.codexChatGptOauthToken) envLines.push(`export CODEX_CHATGPT_OAUTH_TOKEN=${shellQuote(opts.codexChatGptOauthToken)}`); + await sandbox.writeFiles([{ path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }]); + await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); + + // 2) ~/.codex/config.toml — minimal model + sandbox profile + const configToml = [ + `model = "${opts.model}"`, + `approval_policy = "never"`, + `sandbox_mode = "workspace-write"`, + ].join("\n") + "\n"; + await sandbox.writeFiles([{ path: "/tmp/config.toml", content: Buffer.from(configToml) }]); + await sandbox.runCommand("bash", ["-c", "mkdir -p $HOME/.codex && mv /tmp/config.toml $HOME/.codex/config.toml"]); + + // 3) skills (~/.agents/skills is Codex's native scope) + await installSkillsToAgentsDir(sandbox); + + // 4) commit-guard script (Codex flavour, JSON-on-stdout) + await this.writeCommitGuardScript(sandbox); + + // 5) Arthur tracer (no-op if unconfigured) + if (opts.arthur) await this.installArthurTracer(sandbox, opts.arthur); + } + + async setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise { + await this.writeCommitGuardScript(sandbox); // idempotent + await this.mergeHooks(sandbox, { commitGuard: enabled ? "enable" : "disable" }); + } + + buildPhaseScript(opts: PhaseScriptOpts): string { + const { paths, jsonSchema, model, phase } = opts; + + const flags: string[] = [ + `--model "${model}"`, + `--full-auto`, + `--skip-git-repo-check`, + `--json`, + `-o ${paths.structuredOutput}`, + ]; + + let schemaBlock = ""; + if (jsonSchema) { + const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); + schemaBlock = [ + `cat > /tmp/${phase}-schema.json << 'SCHEMA_EOF'`, + escapedSchema, + "SCHEMA_EOF", + ].join("\n"); + flags.push(`--output-schema /tmp/${phase}-schema.json`); + } + + return `#!/bin/bash + +# --- Cleanup stale files --- +rm -f ${paths.sentinel} ${paths.stdout} ${paths.stderr} ${paths.structuredOutput} + +# --- Source auth env vars --- +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +${schemaBlock} + +# --- Phase: ${phase} --- +cat ${paths.input} | codex exec \\ + ${flags.join(" \\\n ")} \\ + - \\ + > ${paths.stdout} 2> ${paths.stderr}; echo $? > /tmp/${phase}-exit-code || true + +# --- Cleanup --- +cd /vercel/sandbox +rm -rf .codex/ +git checkout -- .codex/ 2>/dev/null || true + +touch ${paths.sentinel} +`; + } + + artifactPaths(phase: PhaseKind): PhaseArtifactPaths { + return { + wrapper: `/tmp/${phase}-wrapper.sh`, + input: `/tmp/${phase}-requirements.md`, + stdout: `/tmp/${phase}-stdout.txt`, + stderr: `/tmp/${phase}-stderr.txt`, + sentinel: `/tmp/${phase}-done`, + structuredOutput: `/tmp/${phase}-result.json`, + }; + } + + parseAgentOutput(raw: string, structured: string | null): AgentOutput { + if (structured) { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(structured)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + const text = unwrapLastItemCompleted(raw); + if (text) { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(text)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + if (!raw.trim() && !structured) { + return { result: "failed", error: "Codex produced no output" }; + } + return { + result: "failed", + error: `Codex output unparseable. First 500: ${(structured ?? raw).slice(0, 500)}`, + }; + } + + parseReviewOutput(raw: string, structured: string | null): ReviewOutput { + if (structured) { + try { + const parsed = reviewOutputSchema.safeParse(JSON.parse(structured)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + const text = unwrapLastItemCompleted(raw); + if (text) { + try { + const parsed = reviewOutputSchema.safeParse(JSON.parse(text)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + return { + result: "failed", feedback: "", issues: [], + error: `Codex review output unparseable. First 500: ${(structured ?? raw).slice(0, 500)}`, + }; + } + + parseResearchStatus(raw: string, structured: string | null): ResearchResult { + const text = (structured ?? unwrapLastItemCompleted(raw) ?? raw).trim(); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const m = (lines[i] ?? "").trim().match(/^STATUS:\s*([a-z_]+)/i); + if (!m) continue; + const status = m[1].toLowerCase(); + if (status === "completed" || status === "clarification_needed" || status === "failed") { + return { status, body: lines.slice(i + 1).join("\n").trim() }; + } + } + return { status: "failed", body: text }; + } + + extractUsage(raw: string, _structured: string | null): PhaseUsage | null { + if (!raw.trim()) return null; + let input = 0, cached = 0, output = 0, turns = 0; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event?.type === "turn.completed" && event.usage) { + input += numOr0(event.usage.input_tokens); + cached += numOr0(event.usage.cached_input_tokens); + output += numOr0(event.usage.output_tokens); + turns += 1; + } + } catch { /* ignore non-JSON lines */ } + } + if (turns === 0) return null; + return { + cost_usd: null, + tokens: { input, cached_input: cached, output }, + duration_ms: 0, + duration_api_ms: 0, + num_turns: turns, + }; + } + + // --- private helpers --- + + private async writeCommitGuardScript(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("bash", [ + "-c", + [ + "mkdir -p ~/.codex/hooks", + "cat > ~/.codex/hooks/commit-guard.sh << 'SCRIPT'", + "#!/bin/bash", + "input=$(cat)", + `if echo "$input" | grep -q '"already_blocked":true'; then echo '{"continue": true}'; exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.codex/' | grep -v '^?? \\.codex/')`, + `if [ -n "$changes" ]; then`, + ` printf '{"continue": false, "stopReason": "You have uncommitted changes. Commit them with a descriptive message or revert before stopping."}\\n'`, + " exit 0", + "fi", + `echo '{"continue": true}'`, + "SCRIPT", + "chmod +x ~/.codex/hooks/commit-guard.sh", + ].join("\n"), + ]); + } + + private async mergeHooks( + sandbox: RunnableSandbox, + opts: { commitGuard?: "enable" | "disable"; arthur?: "install" }, + ): Promise { + const arthurEvents = JSON.stringify(ARTHUR_HOOK_EVENTS); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const arthurEvents = ${arthurEvents}; + const home = process.env.HOME; + const cfgPath = path.join(home, '.codex', 'hooks.json'); + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsert = (event, command) => { + const arr = s.hooks[event] || []; + const has = arr.some(e => e && e.command === command); + if (!has) arr.push({ type: 'command', command }); + s.hooks[event] = arr; + }; + const remove = (event, predicate) => { + const arr = s.hooks[event] || []; + s.hooks[event] = arr.filter(e => !predicate(e?.command || '')); + }; + + if (opts.commitGuard === 'enable') upsert('Stop', 'bash ~/.codex/hooks/commit-guard.sh'); + else if (opts.commitGuard === 'disable') remove('Stop', c => c.includes('commit-guard.sh')); + + if (opts.arthur === 'install') { + for (const [event, arg] of arthurEvents) { + upsert(event, 'python3 "$HOME/.codex/hooks/claude_code_tracer.py" ' + arg); + } + } + fs.writeFileSync(cfgPath, JSON.stringify(s, null, 2)); + `; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); + } + + private async installArthurTracer( + sandbox: RunnableSandbox, + arthur: NonNullable, + ): Promise { + const { logger } = await import("../../lib/logger.js"); + logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId, agent: this.kind }, "agent_install_arthur_started"); + + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", + ]); + if (pip.exitCode !== 0) { logger.warn({}, "arthur_pip_install_failed"); return; } + + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([{ path: "/tmp/arthur-tracer.py", content: tracerBytes }]); + const mvTracer = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.codex/hooks && mv /tmp/arthur-tracer.py $HOME/.codex/hooks/claude_code_tracer.py && chmod +x $HOME/.codex/hooks/claude_code_tracer.py", + ]); + if (mvTracer.exitCode !== 0) { logger.warn({}, "arthur_tracer_install_failed"); return; } + + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, null, 2, + ); + await sandbox.writeFiles([{ path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.codex && mv /tmp/arthur_config.json $HOME/.codex/arthur_config.json && chmod 600 $HOME/.codex/arthur_config.json", + ]); + + await this.mergeHooks(sandbox, { arthur: "install" }); + logger.info({ agent: this.kind }, "agent_install_arthur_complete"); + } +} + +// --- module-private helpers --- + +function shellQuote(val: string): string { + return `'${val.replace(/'/g, "'\\''")}'`; +} + +function numOr0(x: unknown): number { return typeof x === "number" ? x : 0; } + +/** Walk Codex NDJSON in reverse for the last `item.completed` event with assistant text. */ +function unwrapLastItemCompleted(raw: string): string | null { + if (!raw.trim()) return null; + const lines = raw.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line || !line.trim()) continue; + try { + const event = JSON.parse(line); + if (event?.type === "item.completed" && event.item) { + if (typeof event.item.text === "string") return event.item.text; + if (typeof event.item.content === "string") return event.item.content; + } + } catch { /* not JSON */ } + } + return null; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm vitest run src/sandbox/agents/codex.test.ts` +Expected: PASS. + +--- + +### Task 14: Wire Codex into the factory + +**Files:** +- Modify: `src/sandbox/agents/index.ts` +- Modify: `src/sandbox/agents/index.test.ts` + +- [ ] **Step 1: Update the test** + +```ts +// add to src/sandbox/agents/index.test.ts +it("returns CodexAgentAdapter for kind=codex", () => { + const a = createAgentAdapter("codex"); + expect(a.kind).toBe("codex"); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm vitest run src/sandbox/agents/index.test.ts` +Expected: FAIL — current factory throws for codex. + +- [ ] **Step 3: Wire Codex** + +```ts +// src/sandbox/agents/index.ts +import { ClaudeAgentAdapter } from "./claude.js"; +import { CodexAgentAdapter } from "./codex.js"; +import type { AgentAdapter } from "./types.js"; + +export type AgentKind = "claude" | "codex"; + +export function createAgentAdapter(kind: AgentKind): AgentAdapter { + switch (kind) { + case "claude": return new ClaudeAgentAdapter(); + case "codex": return new CodexAgentAdapter(); + default: { + const _exhaustive: never = kind; + throw new Error(`Unknown AGENT_KIND: ${_exhaustive}`); + } + } +} + +export type { AgentAdapter } from "./types.js"; +``` + +- [ ] **Step 4: Run to verify both cases pass** + +Run: `pnpm vitest run src/sandbox/agents/index.test.ts` +Expected: PASS. + +--- + +### Task 15: Thread Codex pricing into `formatUsageReport` + +**Files:** +- Modify: `src/workflows/agent.ts` + +The workflow now resolves a price for the active model once per run and passes it as a closure to `formatUsageReport`. + +- [ ] **Step 1: Update the usage suffix construction** + +In `agentWorkflow`, replace `formatUsageReport(phaseUsages)` call sites with a helper that resolves price once per run: + +```ts +// add inside agentWorkflow, near the top of the try block +const activeModel = agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL; +const priceCache = await (async () => { + if (agentKind !== "codex") return null; + const { fetchModelPrice } = await import("../sandbox/agents/pricing.js"); + try { + return await fetchModelPrice(activeModel); + } catch (err) { + const { logger } = await import("../lib/logger.js"); + logger.warn({ err: (err as Error).message, model: activeModel }, "pricing_fetch_failed"); + return null; + } +})(); + +const priceLookup = priceCache ? () => priceCache : undefined; + +const usageSuffix = () => + Object.keys(phaseUsages).length + ? `\n${formatUsageReport(phaseUsages, priceLookup, activeModel)}` + : ""; +``` + +Replace any other direct `formatUsageReport(phaseUsages)` calls in the file with `formatUsageReport(phaseUsages, priceLookup, activeModel)`. + +- [ ] **Step 2: Typecheck + run unit suite** + +Run: `pnpm typecheck && pnpm test` +Expected: PASS. + +- [ ] **Step 3: Commit Phase 2** + +```bash +git add env.ts .env.example src/sandbox/agents/codex.ts src/sandbox/agents/codex.test.ts \ + src/sandbox/agents/pricing.ts src/sandbox/agents/pricing.test.ts \ + src/sandbox/agents/index.ts src/sandbox/agents/index.test.ts \ + src/sandbox/usage.ts src/workflows/agent.ts +git commit -m "feat(sandbox): add Codex agent adapter with pricing-aware usage reports" +``` + +--- + +## Phase 3 — Codex E2E + +### Task 16: Add gated `e2e/codex-tier-1.test.ts` + +**Files:** +- Create: `e2e/codex-tier-1.test.ts` +- Modify: `e2e/vitest.e2e.config.ts` (new project entry) + +The e2e provisions a sandbox with `AGENT_KIND=codex`, runs the impl phase against a tiny seeded ticket, and asserts a commit + PR. It is skipped unless `CODEX_API_KEY` is set in the environment. + +- [ ] **Step 1: Add the test file** + +```ts +// e2e/codex-tier-1.test.ts +import { describe, it, expect, afterAll } from "vitest"; +import { + createTestTicket, + moveTicketToColumn, + getTicketStatus, + deleteTicket, +} from "./helpers/jira.js"; +import { findPR, deleteBranch } from "./helpers/github.js"; +import { cleanup as redisCleanup } from "./helpers/redis.js"; +import { stopSandboxesForTicket } from "./helpers/sandbox.js"; +import { waitFor } from "./helpers/wait.js"; +import { e2eEnv } from "./env.js"; + +const HAVE_CODEX = Boolean(process.env.CODEX_API_KEY); +const guard = HAVE_CODEX ? describe : describe.skip; + +guard("Codex Tier-1: clear ticket → PR via codex exec", () => { + let ticketKey: string; + let branchName: string; + + afterAll(async () => { + if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); + if (branchName) await deleteBranch(branchName).catch(() => {}); + if (ticketKey) { + await redisCleanup(ticketKey); + await deleteTicket(ticketKey); + } + }); + + it("provisions a Codex sandbox, commits, and opens a PR", async () => { + // Sanity — the harness must already have AGENT_KIND=codex set in process.env + expect(process.env.AGENT_KIND).toBe("codex"); + + const ticket = await createTestTicket({ + summary: "[E2E codex] Add GET /api/health endpoint", + description: [ + "Create a GET /api/health route that returns JSON { status: \"ok\" } with HTTP 200.", + "Acceptance:", + "- Route file at app/api/health/route.ts", + "- Returns { status: \"ok\" } with HTTP 200", + ].join("\n"), + }); + ticketKey = ticket.ticketKey; + branchName = `blazebot/${ticketKey.toLowerCase()}`; + + await moveTicketToColumn(ticketKey, e2eEnv.COLUMN_AI); + + // Wait for the workflow to push a commit and open the PR. + const pr = await waitFor(async () => findPR(branchName), { timeoutMs: 30 * 60_000, intervalMs: 30_000 }); + expect(pr).not.toBeNull(); + + // Ticket should land in AI Review. + await waitFor(async () => { + const s = await getTicketStatus(ticketKey); + return s === e2eEnv.COLUMN_AI_REVIEW ? s : null; + }, { timeoutMs: 5 * 60_000 }); + }); +}); +``` + +- [ ] **Step 2: Add a `codex` project entry to `e2e/vitest.e2e.config.ts`** + +Append inside `projects: [...]`: + +```ts +{ + test: { + name: "codex", + include: ["e2e/codex-tier-1.test.ts"], + testTimeout: 4_200_000, + hookTimeout: 4_200_000, + }, +}, +``` + +- [ ] **Step 3: Add a script to `package.json`** + +```json +"test:e2e:codex": "AGENT_KIND=codex vitest run --config e2e/vitest.e2e.config.ts --project codex" +``` + +- [ ] **Step 4: Validate manually** + +With `CODEX_API_KEY` set in `.env.e2e` (and `AGENT_KIND=codex` for that run): + +Run: `pnpm test:e2e:codex` +Expected: PASS — sandbox provisions, the impl phase commits a change, the PR is created. (Manual; do not gate the regular CI on it.) + +- [ ] **Step 5: Commit Phase 3** + +```bash +git add e2e/codex-tier-1.test.ts e2e/vitest.e2e.config.ts package.json +git commit -m "test(e2e): add gated Tier-1 Codex agent run" +``` + +--- + +## Phase 4 — Documentation + +### Task 17: README + .env.example final pass + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add an "Agent" subsection to README** + +Insert after the existing "Agent" block in `### 3. Configure environment variables`: + +```md +**Switching agents** — Blazebot supports two CLI runtimes. Set `AGENT_KIND` once per deployment: + +```bash +AGENT_KIND=claude # default — Anthropic Claude Code +# or +AGENT_KIND=codex # OpenAI Codex CLI +``` + +When `AGENT_KIND=codex`: + +```bash +CODEX_API_KEY=sk-codex-xxxxxxxxxxxx # or CODEX_CHATGPT_OAUTH_TOKEN +CODEX_MODEL=gpt-5-codex # default +``` + +Pricing is fetched from [LiteLLM's community-maintained JSON](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) on each cold start (1h TTL by default). Override `CODEX_PRICING_URL` in airgapped environments. When pricing is unavailable, Slack reports show tokens-only with `cost unknown`. +``` + +- [ ] **Step 2: Update the Environment Variables Reference table** + +Add rows for `AGENT_KIND`, `CODEX_API_KEY`, `CODEX_CHATGPT_OAUTH_TOKEN`, `CODEX_MODEL`, `CODEX_PRICING_URL`, `CODEX_PRICING_TTL_MS` matching the existing table style. + +- [ ] **Step 3: Confirm `.env.example` already covers these** (set up in Task 11). If not, add the same block now. + +- [ ] **Step 4: Commit Phase 4** + +```bash +git add README.md +git commit -m "docs: document AGENT_KIND switching and Codex pricing source" +``` + +--- + +## Pre-implementation verifications (first 30 minutes) + +These three checks belong at the start of Phase 2 (before Task 11) — they're not blocking, but a fast-fail saves time downstream. Each check is its own discrete sub-task: + +1. **LiteLLM JSON reachable + has `gpt-5-codex`.** + ```bash + curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json | jq '."gpt-5-codex"' + ``` + Expected: an object with `input_cost_per_token` and `output_cost_per_token`. If missing, log `gpt-5` and `gpt-5-mini` for fallback documentation in the README. + +2. **`skills` CLI accepts `--target`.** Inside a throwaway sandbox or local node: + ```bash + npx -y skills add https://github.com/obra/superpowers --skill using-superpowers --yes --target /tmp/skills-target-test + test -d /tmp/skills-target-test/using-superpowers + ``` + Expected: directory exists. If the flag is unsupported, switch the install in `agents/shared.ts` to `--global` for `~/.claude/skills` and rewrite Codex's symlink as `~/.agents/skills → ~/.claude/skills` instead. + +3. **`codex --output-schema` validation behaviour.** Inside a Codex sandbox: + ```bash + echo "say hi" | codex exec --json --output-schema /tmp/strict-schema.json -o /tmp/r.json - + ``` + With a deliberately strict schema. Confirm: validation failure does not crash the run — Codex emits `error` events and `r.json` is missing/empty. Adapter parsers already fall back to NDJSON `item.completed`; if Codex actually crashes, parsers must also handle the `error` event. Add a test for that path if observed. + +--- + +## Net Change Summary + +- **New files:** `src/sandbox/agents/{types,claude,codex,shared,index,pricing}.ts` plus tests, `e2e/codex-tier-1.test.ts`. +- **Modified:** `src/sandbox/manager.ts`, `src/sandbox/manager.test.ts`, `src/sandbox/poll-agent.ts`, `src/sandbox/poll-agent.test.ts`, `src/sandbox/usage.ts`, `src/sandbox/usage.test.ts`, `src/workflows/agent.ts`, `env.ts`, `.env.example`, `README.md`, `e2e/vitest.e2e.config.ts`, `package.json`. +- **Deleted:** `src/sandbox/wrapper-script.ts`, `src/sandbox/wrapper-script.test.ts`, `src/sandbox/agent-runner.ts`, `src/sandbox/agent-runner.test.ts`. +- **Untouched:** every adapter under `src/adapters/`, every helper under `src/lib/`, every route under `src/routes/`, `src/workflows/prompts-step.ts`, all run-registry / reconcile / dispatch / Jira webhook / cron / attachments / Arthur client code. diff --git a/docs/superpowers/specs/2026-04-27-codex-integration-design.md b/docs/superpowers/specs/2026-04-27-codex-integration-design.md new file mode 100644 index 0000000..a4f0dfc --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-codex-integration-design.md @@ -0,0 +1,423 @@ +# Codex Integration — Design + +**Date:** 2026-04-27 +**Status:** Draft +**Branch:** AIW-1-codex + +## Goal + +Add OpenAI's Codex CLI (`@openai/codex`) as a second agent runtime alongside Claude Code. Operators choose at deploy time via `AGENT_KIND=claude|codex`. Both agents reach full feature parity for the existing three-phase workflow (research → impl → review): same skills, same commit-guard, same Arthur tracing, same structured output, same usage reporting. The change introduces a thin `AgentAdapter` abstraction and refactors the sandbox layer to use it; everything else (workflow orchestration, VCS, issue tracker, messaging, run registry, reconcile, dispatch) is untouched. + +## Decisions + +| Question | Decision | +|----------|----------| +| Replace Claude or add Codex alongside? | **Add alongside**, env-switched | +| Switching mechanism | `AGENT_KIND=claude\|codex` env var (single, deploy-scoped) | +| Gap-fill strategy | **Full parity** — skills, hooks, structured output, tracing all reach Codex | +| Codex variant | `@openai/codex` CLI | +| Phase parity | Same three phases for both agents | +| Default Codex model | `gpt-5-codex` | +| Architecture | Thin `AgentAdapter` interface in `src/sandbox/agents/` | +| Skills location | `~/.agents/skills/` only — never in the repo | +| Pricing | Fetched dynamically from LiteLLM's maintained JSON; tokens-only fallback | + +## Architecture + +A new `AgentAdapter` interface owns everything CLI-specific. `SandboxManager` becomes thin and orchestrator-only. Workflow code (`src/workflows/agent.ts`) threads the adapter through phase steps but otherwise keeps its current shape. + +```ts +// src/sandbox/agents/types.ts +export type PhaseKind = "research" | "impl" | "review"; + +export interface AgentAdapter { + kind: "claude" | "codex"; + install(sandbox: RunnableSandbox): Promise; + configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise; + setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise; + buildPhaseScript(opts: PhaseScriptOpts): string; + artifactPaths(phase: PhaseKind): { + wrapper: string; + input: string; + stdout: string; + stderr: string; + sentinel: string; + /** Schema-validated JSON file (Codex --output-schema). null for Claude. */ + structuredOutput: string | null; + }; + parseAgentOutput(raw: string, structured: string | null): AgentOutput; + parseReviewOutput(raw: string, structured: string | null): ReviewOutput; + parseResearchStatus(raw: string, structured: string | null): ResearchResult; + extractUsage(raw: string, structured: string | null): PhaseUsage | null; +} +``` + +The Claude adapter ignores the `structured` argument in every parser — Claude embeds its schema-validated output directly in the NDJSON stream, so `paths.structuredOutput` is `null` and only `raw` matters. The Codex adapter prefers `structured` when present and falls back to `raw` (NDJSON `item.completed` scan) when the schema file is missing. The unified signature lets the workflow stay agent-agnostic. + +`createAgentAdapter(env)` picks the implementation at startup based on `AGENT_KIND`. Required credentials are validated by `env.ts` cross-field rules — if `AGENT_KIND=codex` is set without `CODEX_API_KEY` (or `CODEX_CHATGPT_OAUTH_TOKEN`), the server fails fast at startup. + +## File Layout + +```text +src/sandbox/ + agents/ + types.ts # AgentAdapter interface, shared types + claude.ts # Existing Claude logic, refactored into the adapter + codex.ts # New: codex exec wrapper, hooks.json, --output-schema parsing + shared.ts # GLOBAL_SKILLS, commit-guard script body, hook helpers + pricing.ts # fetchModelPrice(model) — LiteLLM-backed, TTL-cached + index.ts # createAgentAdapter(env) factory + claude.test.ts + codex.test.ts + pricing.test.ts + index.test.ts + manager.ts # Slimmed: provision() calls agent.install + agent.configure + poll-agent.ts # Adds collectPhase helper (raw + structured) + context.ts # Unchanged + attachments.ts # Unchanged + usage.ts # PhaseUsage type moves to agents/types.ts; formatUsageReport accepts a price lookup +``` + +Files **deleted** as part of the refactor: +- `src/sandbox/wrapper-script.ts` — body moves into each adapter's `buildPhaseScript` +- `src/sandbox/agent-runner.ts` — schema constants and parsers move into the adapters; if the file ends up empty it is deleted + +Functions **replaced** as part of the refactor: +- `configureStopHookInSandbox` (currently in `manager.ts`) → becomes `agent.setCommitGuard(sandbox, enabled)` on the adapter. The standalone export is removed; `workflows/agent.ts` calls it through the adapter. +- `installArthurTracer` (currently in `manager.ts`, Claude-shaped) → moves into each adapter's `configure()` step. Claude installs to `~/.claude/`; Codex installs to `~/.codex/` with a Codex-shaped `hooks.json`. +- The free `buildPhaseScript` import in `workflows/agent.ts` → becomes `agent.buildPhaseScript(...)` calls. + +Files **untouched**: `src/adapters/**`, `src/lib/**`, `src/routes/**`, `src/workflows/prompts-step.ts`, `src/workflows/prompts-step.test.ts`, all of the issue-tracker / VCS / messaging / run-registry code. + +## Data Flow per Ticket (Codex) + +```text +1. Cron poll → dispatch (unchanged) +2. agentWorkflow(ticketId) + a. fetchAndValidateTicket / fetchPRContext / fetchAttachments / ensureArthurTaskForTicket (unchanged) + b. provisionSandbox: + - SandboxManager.provision(branch, mergeBase) clones the repo (unchanged) + - Constructs the adapter via createAgentAdapter(env) → CodexAgentAdapter + - agent.install(sandbox) → npm i -g @openai/codex + - agent.configure(sandbox, { auth, model, arthur, arthurTaskId }): + · Writes /tmp/agent-env.sh exporting CODEX_API_KEY (or OAuth token) + · Writes ~/.codex/config.toml (model, sandbox profile, fallback file names) + · Installs the global skill set into ~/.agents/skills/ + · Writes ~/.codex/hooks.json with Arthur PreToolUse/PostToolUse/UserPromptSubmit/Stop entries + · Drops ~/.codex/hooks/commit-guard.sh on disk (Codex-flavored JSON output) + c. registerTicketSandbox (unchanged) +3. PHASE 1 (Research): + - agent.setCommitGuard(sandbox, false) + - paths = agent.artifactPaths("research") + - script = agent.buildPhaseScript({ phase: "research", model, ...paths }) + - writeAndStartPhase(sandboxId, paths.input, researchInput, paths.wrapper, script) + - pollUntilDone(sandboxId, paths.sentinel, 20) + - { raw, structured } = collectPhase(sandboxId, paths) + - phaseUsages.Research = agent.extractUsage(raw, structured) + - research = agent.parseResearchStatus(raw, structured) +4. PHASE 2 (Impl): + - agent.setCommitGuard(sandbox, true) + - script = agent.buildPhaseScript({ phase: "impl", ..., jsonSchema: AGENT_SCHEMA }) + - same write/poll/collect flow + - implOutput = agent.parseAgentOutput(raw, structured) +5. PHASE 3 (Review): same wiring as Phase 2 (currently disabled in workflow) +6. Push + PR (unchanged) +7. Teardown (unchanged) +``` + +The Claude flow is the same with `paths.structuredOutput === null` and the existing parsers; behavior is bit-compatible with what Blazebot does today. + +## Auth, Models, Env Config + +**New env vars (added to `env.ts`):** + +```ts +AGENT_KIND: z.enum(["claude", "codex"]).default("claude"), + +// Codex auth — at least one required when AGENT_KIND=codex. +CODEX_API_KEY: z.string().min(1).optional(), +CODEX_CHATGPT_OAUTH_TOKEN: z.string().min(1).optional(), + +// Codex model selection. +CODEX_MODEL: z.string().default("gpt-5-codex"), + +// Pricing — LiteLLM's community-maintained JSON. Operators in airgapped +// environments override; default works for the common case. +CODEX_PRICING_URL: z.string().url().default( + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" +), +CODEX_PRICING_TTL_MS: z.coerce.number().int().positive().default(3_600_000), +``` + +**Cross-field validation** (next to the existing `VCS_KIND` check in `env.ts`): + +```ts +if (env.AGENT_KIND === "codex" && !env.CODEX_API_KEY && !env.CODEX_CHATGPT_OAUTH_TOKEN) { + throw new Error("AGENT_KIND=codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN"); +} +if (env.AGENT_KIND === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + throw new Error("AGENT_KIND=claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN"); +} +``` + +Existing `CLAUDE_MODEL` / `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` keep their Claude-specific names — they are a public contract for current operators. We do not rename to a generic `AGENT_MODEL` / `AGENT_API_KEY`; agent-scoped names are clearer. + +**`.env.example` additions:** + +```bash +# Agent (claude | codex) +AGENT_KIND=claude + +# Codex (only when AGENT_KIND=codex) +CODEX_API_KEY= +CODEX_CHATGPT_OAUTH_TOKEN= # alternative to CODEX_API_KEY +CODEX_MODEL=gpt-5-codex +``` + +**README** gets a short "Agent" subsection covering: how to switch, which envs are required for each kind, and a pointer to the LiteLLM JSON for the model pricing data we use. + +## Skills Strategy + +**Single source of truth: `~/.agents/skills/` inside the sandbox.** Both agents read from there. We never write to or read from the repo's own `.agents/skills/` directory. + +**Adapter steps in `configure()`:** + +- Both adapters call a shared helper `installSkillsToAgentsDir(sandbox)` which runs `npx -y skills add --skill --yes --target ~/.agents/skills` for each entry in `GLOBAL_SKILLS`. +- The Claude adapter additionally creates the symlink `~/.claude/skills → ~/.agents/skills` so Claude's auto-discovery finds the same content. +- The Codex adapter does nothing extra — `~/.agents/skills/` is its native user-scope path. + +**`shared.ts` exports:** + +```ts +export const GLOBAL_SKILLS = [ + { repo: "https://github.com/obra/superpowers", skill: "using-superpowers" }, + { repo: "https://github.com/obra/superpowers", skill: "requesting-code-review" }, + { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, +] as const; + +export async function installSkillsToAgentsDir(sandbox: RunnableSandbox): Promise; +``` + +The skill-frontmatter format (`name`, `description`) is identical for both agents. Existing prompts in `src/lib/prompts.ts` reference skills by name only and never mention agent-specific paths or Claude/Codex by name — they work as-is. + +**Verification before shipping:** confirm the `skills` CLI accepts the `--target` flag against the version Blazebot installs. If not, fall back to installing into `~/.claude/skills/` and symlinking `~/.agents/skills → ~/.claude/skills` for Codex. Same outcome either direction. + +## Hooks: Commit-guard + Arthur Tracing for Codex + +Codex's hook system is shaped almost identically to Claude's: same event names (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`), same `command`-type entries, JSON in/out protocol. The differences: + +| | Claude | Codex | +|---|---|---| +| Config file | `~/.claude/settings.json` | `~/.codex/hooks.json` | +| Stop signal | `{"decision":"block","reason":"..."}` to stderr + exit 2 | `{"continue":false,"stopReason":"..."}` to stdout + exit 0 | +| Continue signal | exit 0 / no output | `{"continue":true}` or exit 0 / no output | + +**Commit-guard for Codex:** + +```bash +# ~/.codex/hooks/commit-guard.sh +#!/bin/bash +input=$(cat) +if echo "$input" | grep -q '"already_blocked":true'; then echo '{"continue": true}'; exit 0; fi +changes=$(git status --porcelain | grep -v '^.. \.codex/' | grep -v '^?? \.codex/') +if [ -n "$changes" ]; then + printf '{"continue": false, "stopReason": "You have uncommitted changes. Commit them with a descriptive message or revert before stopping."}\n' + exit 0 +fi +echo '{"continue": true}' +``` + +Registered in `~/.codex/hooks.json` under `Stop`. `agent.setCommitGuard(sandbox, true|false)` upserts/removes the entry — keyed on the script path so other tools' hooks (Arthur) are not disturbed. + +**Phase toggle semantics (same as Claude):** +- Off during research (must allow exit without commits) +- On during impl + review (forces the agent to commit before claiming done) + +**Arthur tracing:** the existing tracer Python script and config file are agent-agnostic. The Codex adapter installs the same `~/.claude/hooks/claude_code_tracer.py` content (renamed to `~/.codex/hooks/claude_code_tracer.py`) plus the same `arthur_config.json`, then writes Codex-format hook entries pointing at `python3 "$HOME/.codex/hooks/claude_code_tracer.py" ` for `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`. The Arthur OTLP/HTTP exporter doesn't care which CLI emitted the events. + +## Output Parsing — Codex Specifics + +**Two artifacts per phase:** + +1. **`/tmp/-stdout.txt`** — NDJSON event stream from `codex exec --json`. One JSON object per line. Relevant types: `thread.started`, `turn.started`, `turn.completed` (carries `usage`), `item.completed` (carries assistant text), `error`. +2. **`/tmp/-result.json`** — final assistant message, schema-validated when `--output-schema` is supplied. Written by `-o`. For research (no schema) this contains free-form markdown with the `STATUS:` line on top; for impl/review it contains the JSON object matching `AGENT_SCHEMA` / `REVIEW_SCHEMA`. + +**Codex `buildPhaseScript`:** the function returns a bash script string (same shape as today's `buildPhaseScript`). Two variants depending on whether `jsonSchema` is supplied: + +Research phase (no schema, free-form markdown output): + +```bash +#!/bin/bash +rm -f /tmp/research-done /tmp/research-stdout.txt /tmp/research-stderr.txt /tmp/research-result.json +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +cat /tmp/research-requirements.md | codex exec \ + --model "${model}" \ + --full-auto \ + --skip-git-repo-check \ + --json \ + -o /tmp/research-result.json \ + - \ + > /tmp/research-stdout.txt 2> /tmp/research-stderr.txt; echo $? > /tmp/research-exit-code || true + +cd /vercel/sandbox +rm -rf .codex/ +git checkout -- .codex/ 2>/dev/null || true +touch /tmp/research-done +``` + +Impl/review phase (schema-validated JSON output): + +```bash +#!/bin/bash +rm -f /tmp/impl-done /tmp/impl-stdout.txt /tmp/impl-stderr.txt /tmp/impl-result.json +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +cat > /tmp/impl-schema.json << 'SCHEMA_EOF' +${jsonSchema} +SCHEMA_EOF + +cat /tmp/impl-requirements.md | codex exec \ + --model "${model}" \ + --full-auto \ + --skip-git-repo-check \ + --json \ + --output-schema /tmp/impl-schema.json \ + -o /tmp/impl-result.json \ + - \ + > /tmp/impl-stdout.txt 2> /tmp/impl-stderr.txt; echo $? > /tmp/impl-exit-code || true + +cd /vercel/sandbox +rm -rf .codex/ +git checkout -- .codex/ 2>/dev/null || true +touch /tmp/impl-done +``` + +The schema heredoc uses `'SCHEMA_EOF'` (quoted) so the body is not subject to shell expansion. Schema content with embedded single quotes is escaped at TS-template level — same approach as today's Claude wrapper for `--json-schema`. + +`--full-auto` is the documented happy path for non-interactive automation: upgrades to `workspace-write` and grants approval-less execution. We do **not** use `--yolo` / `--dangerously-bypass-approvals-and-sandbox`. `--skip-git-repo-check` is defensive (the sandbox can be in MERGING state during review-fix). `--ephemeral` is **not** used — session files help debug failed runs from a still-running sandbox before teardown. + +**Parsers in `codex.ts`:** + +```ts +parseAgentOutput(raw, structured) { + if (structured) { + try { const parsed = agentOutputSchema.safeParse(JSON.parse(structured)); + if (parsed.success) return parsed.data; } catch {} + } + return scanItemCompletedAsAgentOutput(raw) + ?? { result: "failed", error: `Codex output unparseable. First 500: ${raw.slice(0, 500)}` }; +} + +parseReviewOutput(raw, structured) { /* same shape, reviewOutputSchema */ } + +parseResearchStatus(raw, structured) { + // Research has no schema. Prefer structured (the -o file holds the assistant message). + // Fallback: scan NDJSON for the last item.completed text. + const text = structured ?? unwrapLastItemCompleted(raw); + return parseStatusLine(text); +} + +extractUsage(raw, _structured) { + // Walk NDJSON in reverse for type === "turn.completed"; sum usage across turns. + // Returns { cost_usd: null, tokens: { input, cached_input, output }, duration_ms, num_turns } +} +``` + +**`collectPhase` helper** (new, in `poll-agent.ts`): + +```ts +export async function collectPhase( + sandboxId: string, + paths: { stdout: string; stderr: string; structuredOutput: string | null }, +): Promise<{ raw: string; structured: string | null }>; +``` + +Reads stdout (with stderr fallback when stdout is empty, mirroring existing `collectPhaseOutput`), reads `structuredOutput` if non-null. Workflow swaps `collectPhaseOutput` calls for this. + +## Pricing + +**`PhaseUsage` shape — agent-agnostic:** + +```ts +export interface PhaseUsage { + cost_usd: number | null; // populated by Claude directly; computed from tokens for Codex + tokens: { input: number; cached_input: number; output: number } | null; + duration_ms: number; + duration_api_ms: number; + num_turns: number; +} +``` + +- Claude's `extractUsage` returns `cost_usd` from its envelope (Claude CLI computes the dollars itself). +- Codex's `extractUsage` returns `cost_usd: null` and `tokens` from `turn.completed`. + +**`pricing.ts`:** + +```ts +export interface TokenPrice { input: number; cached_input: number; output: number } + +/** TTL-cached fetch from CODEX_PRICING_URL. Returns null on miss/failure. */ +export async function fetchModelPrice(model: string): Promise; +``` + +LiteLLM's JSON keys models by canonical name with per-token costs (`input_cost_per_token`, `output_cost_per_token`, `cache_read_input_token_cost`). The module normalizes them to `TokenPrice`. Cache TTL is 1h by default (`CODEX_PRICING_TTL_MS`). + +**`formatUsageReport(phases, priceLookup)`:** for each phase, if `cost_usd != null` use it; else if tokens + price are available, compute `cost = (tokens.input * price.input + tokens.cached_input * price.cached_input + tokens.output * price.output)`; else show tokens-only with the `cost unknown` marker. Always informative, never fabricated. + +**Verification before shipping:** fetch the LiteLLM JSON once, confirm `gpt-5-codex` (and the most likely operator alternatives — `gpt-5`, `gpt-5-mini`) are listed with the expected fields. If a model is missing, the tokens-only fallback handles it gracefully and the operator can override `CODEX_PRICING_URL`. + +## Error Handling & Edge Cases + +| Failure | Handling | +|---|---| +| Schema validation failure (no `result.json` written) | Parser falls back to NDJSON `item.completed` scan; if that fails, returns `failed` and the workflow moves the ticket to BACKLOG via the existing path | +| Hook script missing or unexecutable | `agent.configure` does `chmod +x` and asserts `test -f`; throws if missing — `provisionSandbox` (`maxRetries=0`) propagates to the workflow's top-level catch | +| Codex CLI install fails | Same as above — surface via `agent.install` throw | +| Pricing fetch fails | Workflow continues; tokens-only Slack output; logged at WARN | +| `turn.completed` missing | `extractUsage` returns null; Slack shows `Phase: n/a` (existing behavior) | +| Sentinel never written | Existing `pollUntilDone` timeout path; ticket → BACKLOG | +| Commit-guard infinite loop | Hook checks `already_blocked` flag and returns `continue: true` on the second invocation; `JOB_TIMEOUT_MS` bounds worst case | +| `AGENT_KIND` changes mid-flight | `provisionSandbox` returns `{ sandboxId, agentKind }`; downstream steps reconstruct adapter from the persisted value, not from the live env | + +**Logging:** +- `agent_install_started` / `agent_install_complete` — tagged with `kind` +- `phase_started` / `phase_completed` — tagged with `kind` +- `pricing_fetch_failed` — WARN with URL, model +- `commit_guard_triggered` — INFO when the hook blocks + +## Testing + +**Unit:** +- `src/sandbox/agents/codex.test.ts` — research status from `result.json`, agent output from `result.json`, fallback to NDJSON `item.completed`, `extractUsage` from `turn.completed` (single + multi-turn), commit-guard JSON shape +- `src/sandbox/agents/claude.test.ts` — relocates the existing parser tests; same coverage as today +- `src/sandbox/agents/index.test.ts` — `createAgentAdapter` selection by `AGENT_KIND`; throws on missing creds +- `src/sandbox/agents/pricing.test.ts` — fetch + cache + fallback, mocked HTTP +- `src/sandbox/manager.test.ts` — refactored to assert delegation to a fake adapter + +**E2E:** +- New `e2e/codex-tier-1.test.ts` — provisions a sandbox with `AGENT_KIND=codex`, runs the impl phase against a tiny seeded ticket, asserts a commit and PR. **Skipped by default**; gated on `CODEX_API_KEY` being set in CI +- Existing Tier-1 / Tier-2 e2e (Claude path) untouched — must pass after the refactor + +## Rollout + +1. **Refactor only** — extract Claude logic into `claude.ts`, introduce `AgentAdapter`, slim `SandboxManager`. Existing tests + Tier-1 e2e must pass. Ship as one commit. +2. **Add Codex adapter** — `codex.ts`, `pricing.ts`, env vars, factory selection. Unit tests pass. No Codex e2e yet. +3. **Codex e2e** — add the gated tier-1 test. Validate manually against a sandbox project, then add a CI job that runs only when `CODEX_API_KEY` is configured. +4. **Documentation** — update README + `.env.example`. Add a short "Switching agents" section. + +## Open Verifications (Pre-Implementation) + +These are first-30-minutes-of-implementation checks, not spec-blocking risks: + +1. LiteLLM JSON URL is reachable and `gpt-5-codex` is listed with the expected fields. Operator override (`CODEX_PRICING_URL`) is the escape hatch if the source moves. +2. The `skills` CLI accepts `--target` against the version Blazebot installs. Fallback: install into `~/.claude/skills/` and symlink `~/.agents/skills → ~/.claude/skills`. +3. Codex's `--output-schema` behavior on validation failure (does it crash the run or surface errors and continue?). Affects how aggressively the parser falls back to the NDJSON scan. + +## Net Change Summary + +- **New files:** `src/sandbox/agents/{types,claude,codex,shared,index,pricing}.ts` + tests, `e2e/codex-tier-1.test.ts` +- **Deleted:** `src/sandbox/wrapper-script.ts`, possibly `src/sandbox/agent-runner.ts` (if it ends up empty) +- **Modified:** `src/sandbox/manager.ts`, `src/sandbox/poll-agent.ts`, `src/sandbox/usage.ts`, `src/workflows/agent.ts`, `env.ts`, `.env.example`, `README.md` +- **Untouched:** all VCS adapters, issue-tracker adapters, messaging adapters, run registry, reconcile, dispatch, Jira webhook, cron, attachments, Arthur client +- **Estimated size:** ~700–900 LOC net add, ~250–350 LOC moved between files diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index 0d6bbcd..778854e 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -30,12 +30,25 @@ async function jiraRequest(path: string, options?: RequestInit) { } export async function createTestTicket( - overrides: { summary?: string; description?: string } = {}, + overrides: { summary?: string; description?: string; labels?: string[] } = {}, ): Promise<{ ticketKey: string; ticketId: string }> { const summary = overrides.summary ?? `[E2E] test-${crypto.randomUUID().slice(0, 8)}`; const description = overrides.description ?? "Automated e2e test ticket"; + // Per-ticket agent override label, set by the e2e workflow input. The + // deployed app's agent.ts reads `agent:` labels via + // parseAgentKindOverride to decide which adapter to spin up. + const envAgent = process.env.E2E_AGENT_KIND?.toLowerCase(); + const autoLabels = + envAgent === "codex" || envAgent === "claude" ? [`agent:${envAgent}`] : []; + // Strip any caller-supplied agent:* labels so the env-driven autoLabel wins + // and the parseAgentKindOverride lookup never sees conflicting entries. + const filteredOverrides = (overrides.labels ?? []).filter( + (l) => !/^agent:/i.test(l), + ); + const labels = [...autoLabels, ...filteredOverrides]; + const data = await jiraRequest("/rest/api/3/issue", { method: "POST", body: JSON.stringify({ @@ -53,6 +66,7 @@ export async function createTestTicket( ], }, issuetype: { name: "Task" }, + ...(labels.length ? { labels } : {}), }, }), }); diff --git a/env.ts b/env.ts index 3640564..8623e6b 100644 --- a/env.ts +++ b/env.ts @@ -46,6 +46,23 @@ export const env = createEnv({ COMMIT_AUTHOR: z.string().default("ai-workflow-blazity"), COMMIT_EMAIL: z.string().default("ai-workflow@blazity.com"), + // Agent kind selection (claude | codex). Defaults to claude for back-compat. + AGENT_KIND: z.enum(["claude", "codex"]).default("claude"), + + // Codex auth — at least one required when AGENT_KIND=codex. + CODEX_API_KEY: z.string().min(1).optional(), + CODEX_CHATGPT_OAUTH_TOKEN: z.string().min(1).optional(), + + // Codex model selection. + CODEX_MODEL: z.string().default("gpt-5-codex"), + + // LiteLLM community-maintained pricing JSON. Operator overridable. + CODEX_PRICING_URL: z + .string() + .url() + .default("https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"), + CODEX_PRICING_TTL_MS: z.coerce.number().int().positive().default(3_600_000), + // Arthur AI Engine (optional — both required together). One task per run // is auto-created, so there is no static GENAI_ENGINE_TASK_ID. GENAI_ENGINE_API_KEY: z.string().min(1).optional(), @@ -104,6 +121,18 @@ export const env = createEnv({ ); } } + if (env.AGENT_KIND === "codex" && !env.CODEX_API_KEY && !env.CODEX_CHATGPT_OAUTH_TOKEN) { + throw new Error( + "Invalid environment variables:\n" + + " AGENT_KIND=codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN", + ); + } + if (env.AGENT_KIND === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + throw new Error( + "Invalid environment variables:\n" + + " AGENT_KIND=claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", + ); + } } export type Env = typeof env; diff --git a/scripts/test-sandbox-skills.ts b/scripts/test-sandbox-skills.ts index 9914845..827f6d5 100644 --- a/scripts/test-sandbox-skills.ts +++ b/scripts/test-sandbox-skills.ts @@ -9,8 +9,6 @@ import { Sandbox } from "@vercel/sandbox"; const INJECTED_SKILLS = [ - { repo: "https://github.com/obra/superpowers", skill: "using-superpowers" }, - { repo: "https://github.com/obra/superpowers", skill: "requesting-code-review" }, { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, ]; diff --git a/src/adapters/issue-tracker/jira.test.ts b/src/adapters/issue-tracker/jira.test.ts index 8be4cc6..1328cbb 100644 --- a/src/adapters/issue-tracker/jira.test.ts +++ b/src/adapters/issue-tracker/jira.test.ts @@ -439,5 +439,38 @@ describe("JiraAdapter", () => { const body = JSON.parse(call[1].body); expect(body.body.type).toBe("doc"); }); + + it("splits multi-line comments into separate paragraphs (no \\n inside text nodes)", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + const adapter = jiraAdapter(); + await adapter.postComment("10001", "1. First question\n2. Second question"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.body.content).toEqual([ + { type: "paragraph", content: [{ type: "text", text: "1. First question" }] }, + { type: "paragraph", content: [{ type: "text", text: "2. Second question" }] }, + ]); + const collectText = (n: any): string => + n?.text ?? (n?.content?.map(collectText).join("") ?? ""); + expect(collectText(body.body)).not.toContain("\n"); + }); + + it("normalizes CRLF line endings into separate paragraphs", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + const adapter = jiraAdapter(); + await adapter.postComment("10001", "1. First question\r\n2. Second question"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.body.content).toEqual([ + { type: "paragraph", content: [{ type: "text", text: "1. First question" }] }, + { type: "paragraph", content: [{ type: "text", text: "2. Second question" }] }, + ]); + const collectText = (n: any): string => + n?.text ?? (n?.content?.map(collectText).join("") ?? ""); + expect(collectText(body.body)).not.toContain("\r"); + expect(collectText(body.body)).not.toContain("\n"); + }); }); }); diff --git a/src/adapters/issue-tracker/jira.ts b/src/adapters/issue-tracker/jira.ts index d3ce023..570223e 100644 --- a/src/adapters/issue-tracker/jira.ts +++ b/src/adapters/issue-tracker/jira.ts @@ -106,12 +106,7 @@ export class JiraAdapter implements IssueTrackerAdapter { body: { type: "doc", version: 1, - content: [ - { - type: "paragraph", - content: [{ type: "text", text: comment }], - }, - ], + content: toAdfParagraphs(comment), }, }), }); @@ -179,6 +174,18 @@ export class JiraAdapter implements IssueTrackerAdapter { } } +function toAdfParagraphs(text: string) { + const lines = text.split(/\r?\n/); + const paragraphs = lines.map((line) => { + if (line === "") return { type: "paragraph" }; + return { + type: "paragraph", + content: [{ type: "text", text: line }], + }; + }); + return paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }]; +} + function extractAdfText(adf: any): string { if (!adf) return ""; if (typeof adf === "string") return adf; diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 0ef7c89..894159a 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -14,13 +14,6 @@ Valid statuses: \`completed\`, \`clarification_needed\`, \`failed\` Everything after the STATUS line is your research findings and plan. This output will be passed as-is to the implementation agent — keep it clean and actionable. -## Superpowers - -You have access to **superpowers skills** installed globally. Use them. - -- **Always check for applicable skills before starting work.** The \`using-superpowers\` skill is loaded — follow its guidance. -- **Use \`brainstorming\` to think through the approach** — explore alternatives, consider trade-offs, then settle on the best path. - ## Process 1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. @@ -29,7 +22,7 @@ You have access to **superpowers skills** installed globally. Use them. 4. If PR review feedback or CI/CD failures are included above, understand what needs to be fixed. **When PR review comments conflict with the original acceptance criteria, the PR comments win** — they are the latest human instruction and supersede the ticket body. Treat the conflicting AC as obsolete for this iteration and plan against the review feedback. Do NOT return \`clarification_needed\` for this kind of conflict. 5. Identify what's already implemented vs. what remains. 6. Analyze relevant files, code patterns, test setup. -7. **Use the \`brainstorming\` skill** to think through the approach. +7. Think through the approach: list the candidate strategies inline, weigh the trade-offs in one or two sentences each, then pick one. 8. Produce a precise implementation plan for the remaining work. 9. **Write/update session memory** — overwrite \`blazebot/memory/[TASK_ID].md\`. @@ -41,8 +34,17 @@ Your plan MUST be: - **Concrete** — file paths must be specific ("src/components/Foo.tsx" not "the relevant component") - **Structured for top-to-bottom execution** — the implementation agent reads and executes sequentially +Your plan MUST NOT contain any of the following steps. They will be enforced as forbidden in the implementation phase, so including them only wastes turns: +- Creating a git worktree, switching to one, or any \`git worktree\` command. +- Modifying \`.gitignore\` unless the ticket itself is about gitignore hygiene. The sandbox already excludes the agent-internal paths it needs. +- "Set up an isolated environment" or "run setup script before starting". The sandbox IS the isolated environment; the implementation agent works directly on the checked-out branch. + +The plan describes what to build for the ticket, not how the agent organizes its own session. + ## When to Ask for Clarification +Clarifications are ONLY for ticket-scope ambiguity that would change what gets implemented. + Return \`STATUS: clarification_needed\` if: - No clear definition of done in the ticket - Ambiguous scope @@ -56,6 +58,18 @@ If the ticket requires assumptions to pick a target or behavior, you MUST ask cl When you need clarification, list your questions as numbered lines after the STATUS line. Batch ALL questions — never return with just one. +### NEVER ask about agent-internal or operational details + +You are running inside a single-purpose, ephemeral sandbox dedicated to this one ticket. There is no shared developer workspace to coordinate with, no preferences to negotiate, no choices the user wants to make about your tooling. Pick a sensible default and proceed silently. + +Forbidden question categories (pick a default and continue, do **NOT** return \`clarification_needed\`): +- Where to create a git worktree, scratch directory, branch name, or temporary file. (You don't need a worktree — the sandbox is already isolated. Work directly on the current branch.) +- Which model, output filename, log path, or session-memory location to use. +- Any "should I use X or Y?" where X and Y are interchangeable implementation details that don't change the user-visible deliverable. +- Permission-style questions ("is it okay if I…", "would you prefer…"). Just do the thing. + +Rule of thumb: if the question is about *how you do your work* rather than *what the user wants built*, do not ask it. Make a reasonable assumption and note it briefly in the plan if it matters. + ## Mandatory Clarity Gate (Before Choosing STATUS: completed) You MUST answer YES to ALL checks below before returning \`STATUS: completed\`: @@ -99,14 +113,6 @@ const implementPrompt = `# Instructions You are an AI coding agent executing an implementation plan. The plan was created by a research agent and is included above under "Research & Plan". -## Superpowers - -You have access to **superpowers skills** installed globally. Use them. - -- **Use \`executing-plans\` to systematically work through the plan** — it structures execution correctly. -- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. -- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. - ## Process 1. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists. If it exists, read it. @@ -124,13 +130,16 @@ You have access to **superpowers skills** installed globally. Use them. - Do not refactor code outside the scope of the plan. - Do not install new dependencies unless the plan specifies them. - Follow existing code conventions (check CLAUDE.md, AGENTS.md if present). -- Do NOT add \`blazebot/memory\` to \`.gitignore\` unless the user explicitly asks you to. Session memory must be committed to the branch. -- Do NOT invoke \`requesting-code-review\` — that happens in a separate review phase. +- **Do NOT modify \`.gitignore\` at all** unless the plan above explicitly says to. The implementation target is feature code, not repository hygiene. Agent-internal paths (\`.worktrees/\`, \`.codex/\`, etc.) are managed by the sandbox, not by you. +- **Do NOT run \`git worktree add\`** or any other worktree command. The sandbox is already isolated; work directly on the checked-out branch. +- Code review happens in a separate phase — do not perform one yourself. ## When to Ask for Clarification Return \`clarification_needed\` only if the plan is genuinely unexecutable. Exhaust code-level investigation first. +**Never** ask the user about agent-internal or operational details (worktree paths, scratch dirs, model choice, output filenames, branch naming). The sandbox is already isolated and dedicated to this ticket — pick a sensible default and proceed silently. Clarifications are for ticket-scope ambiguity only. + ## Session Memory **MANDATORY** — before returning, overwrite \`blazebot/memory/[TASK_ID].md\`: @@ -157,6 +166,11 @@ Return \`clarification_needed\` only if the plan is genuinely unexecutable. Exha ## Output +The JSON object below is your **final report** after you have already edited at least one ticket-relevant file and created at least one git commit. It is not a substitute for doing the work. + +- Do NOT return \`result: "implemented"\` unless you have made at least one ticket-relevant file edit (code, docs, config, or tests addressing the ticket) AND created at least one git commit on this branch. +- A run whose only changed file is \`.gitignore\` is a hard failure — set \`result: "failed"\` and explain in \`error\`. (Non-code edits — docs, config, tests — that genuinely address the ticket DO count as implemented.) + Return a JSON object with: - \`result\`: "implemented" if done, "clarification_needed" if you have questions, "failed" if stuck. - \`summary\`: Description of work done (when implemented). @@ -167,26 +181,16 @@ const reviewPrompt = `# Instructions You are an AI code review agent. Your job is to review the implementation diff against the plan and acceptance criteria, and **fix any issues you find**. -## Superpowers - -You have access to **superpowers skills** installed globally. Use them. - -- **Use \`requesting-code-review\` to dispatch a code-reviewer subagent** — this is your primary tool for identifying issues. -- **Use \`systematic-debugging\` when encountering bugs or test failures** — do not guess at fixes. -- **Use \`verification-before-completion\` before claiming work is done** — verify, don't assume. - ## Process 1. Read the plan from the "Research & Plan" section above. 2. Read the acceptance criteria. 3. Review the git diff against the plan — did the implementation agent follow it? 4. Check code quality, test coverage, edge cases. -5. Invoke \`requesting-code-review\` skill to dispatch a code-reviewer subagent. -6. Combine your findings with the subagent's findings. -7. **Fix any issues found** — apply code changes directly. This is the final phase, there is no re-implementation loop. -8. If you made changes, run tests and quality checks to verify the fixes. -9. Commit any fixes with descriptive commit messages (conventional commits: fix:, refactor:, test:, etc.). -10. Output your verdict. +5. **Fix any issues found** — apply code changes directly. This is the final phase, there is no re-implementation loop. +6. If you made changes, run tests and quality checks to verify the fixes. +7. Commit any fixes with descriptive commit messages (conventional commits: fix:, refactor:, test:, etc.). +8. Output your verdict. ## Review Criteria diff --git a/src/sandbox/agent-runner.test.ts b/src/sandbox/agent-runner.test.ts deleted file mode 100644 index d0581a6..0000000 --- a/src/sandbox/agent-runner.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { - parseAgentOutput, - AGENT_SCHEMA, - type AgentOutput, - parseResearchStatus, - parseReviewOutput, - REVIEW_SCHEMA, -} from "./agent-runner.js"; - -describe("parseAgentOutput", () => { - it("parses implemented result", () => { - const raw = JSON.stringify({ - result: "implemented", - summary: "Added login page with OAuth", - }); - const output = parseAgentOutput(raw); - expect(output.result).toBe("implemented"); - expect(output.summary).toBe("Added login page with OAuth"); - }); - - it("parses clarification_needed result", () => { - const raw = JSON.stringify({ - result: "clarification_needed", - questions: ["What OAuth provider?", "Should we support SSO?"], - }); - const output = parseAgentOutput(raw); - expect(output.result).toBe("clarification_needed"); - expect(output.questions).toHaveLength(2); - }); - - it("parses failed result", () => { - const raw = JSON.stringify({ - result: "failed", - error: "Tests do not pass", - }); - const output = parseAgentOutput(raw); - expect(output.result).toBe("failed"); - expect(output.error).toBe("Tests do not pass"); - }); - - it("extracts JSON from markdown-wrapped output", () => { - const raw = `Here is my result:\n\`\`\`json\n{"result": "implemented", "summary": "Done"}\n\`\`\``; - const output = parseAgentOutput(raw); - expect(output.result).toBe("implemented"); - expect(output.summary).toBe("Done"); - }); - - it("extracts JSON from text-wrapped output", () => { - const raw = `I completed the task.\n{"result": "implemented", "summary": "Added feature"}\nThat's all.`; - const output = parseAgentOutput(raw); - expect(output.result).toBe("implemented"); - }); - - it("returns failed on empty output", () => { - const output = parseAgentOutput(""); - expect(output.result).toBe("failed"); - expect(output.error).toContain("no output"); - }); - - it("returns failed on unparseable output", () => { - const output = parseAgentOutput("not json at all"); - expect(output.result).toBe("failed"); - expect(output.error).toContain("not structured JSON"); - }); - - it("returns failed on JSON missing result field", () => { - const output = parseAgentOutput(JSON.stringify({ summary: "oops" })); - expect(output.result).toBe("failed"); - }); - - it("parses structured_output from result envelope", () => { - const envelope = JSON.stringify({ - type: "result", - subtype: "success", - is_error: false, - result: "I renamed the endpoint.", - structured_output: { result: "implemented", summary: "Renamed endpoint" }, - }); - const output = parseAgentOutput(envelope); - expect(output.result).toBe("implemented"); - expect(output.summary).toBe("Renamed endpoint"); - }); - - it("falls back to event.result as JSON when structured_output is missing", () => { - const envelope = JSON.stringify({ - type: "result", - subtype: "success", - is_error: false, - result: JSON.stringify({ result: "clarification_needed", questions: ["Which DB?"] }), - }); - const output = parseAgentOutput(envelope); - expect(output.result).toBe("clarification_needed"); - expect(output.questions).toEqual(["Which DB?"]); - }); - - it("infers implemented when result envelope has success but text output", () => { - const envelope = JSON.stringify({ - type: "result", - subtype: "success", - is_error: false, - duration_ms: 6404, - num_turns: 1, - result: "\n\nI kept the response as-is to match the acceptance criteria.\n", - }); - const output = parseAgentOutput(envelope); - expect(output.result).toBe("implemented"); - expect(output.summary).toContain("acceptance criteria"); - }); - - it("infers failed when result envelope has error status", () => { - const envelope = JSON.stringify({ - type: "result", - subtype: "error", - is_error: true, - result: "Agent crashed unexpectedly", - }); - const output = parseAgentOutput(envelope); - expect(output.result).toBe("failed"); - expect(output.error).toContain("crashed"); - }); -}); - -describe("AGENT_SCHEMA", () => { - it("is valid JSON", () => { - expect(() => JSON.parse(AGENT_SCHEMA)).not.toThrow(); - }); -}); - -describe("parseResearchStatus", () => { - it("extracts completed status", () => { - const raw = "STATUS: completed\n\n# Implementation Plan\n1. Create foo.ts"; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("completed"); - expect(body).toContain("# Implementation Plan"); - }); - - it("extracts clarification_needed status", () => { - const raw = "STATUS: clarification_needed\n\n1. What database?\n2. Which auth?"; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("clarification_needed"); - expect(body).toContain("What database?"); - }); - - it("extracts failed status", () => { - const raw = "STATUS: failed\n\nCould not access repository"; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("failed"); - }); - - it("defaults to failed when no STATUS line", () => { - const raw = "Here is my analysis of the codebase..."; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("failed"); - expect(body).toContain("analysis"); - }); - - it("handles STATUS line with extra whitespace", () => { - const raw = " STATUS: completed \n\nPlan here"; - const { status } = parseResearchStatus(raw); - expect(status).toBe("completed"); - }); - - it("handles leading blank lines before STATUS", () => { - const raw = "\n\nSTATUS: clarification_needed\n\n1. Which provider?"; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("clarification_needed"); - expect(body).toContain("Which provider?"); - }); - - it("normalizes uppercase status values", () => { - const raw = "STATUS: CLARIFICATION_NEEDED\n\n1. Which provider?"; - const { status } = parseResearchStatus(raw); - expect(status).toBe("clarification_needed"); - }); - - it("extracts STATUS from fenced output", () => { - const raw = "```markdown\nSTATUS: clarification_needed\n\n1. Which provider?\n```"; - const { status, body } = parseResearchStatus(raw); - expect(status).toBe("clarification_needed"); - expect(body).toContain("Which provider?"); - }); -}); - -describe("parseReviewOutput", () => { - it("parses approved result", () => { - const raw = JSON.stringify({ - result: "approved", - feedback: "Looks good", - issues: [], - }); - const output = parseReviewOutput(raw); - expect(output.result).toBe("approved"); - expect(output.feedback).toBe("Looks good"); - }); - - it("parses approved result with issues", () => { - const raw = JSON.stringify({ - result: "approved", - feedback: "Fixed several issues", - issues: [ - { file: "src/foo.ts", description: "Fixed missing null check", severity: "critical" }, - ], - }); - const output = parseReviewOutput(raw); - expect(output.result).toBe("approved"); - expect(output.issues).toHaveLength(1); - expect(output.issues[0].severity).toBe("critical"); - }); - - it("returns failed on unparseable output", () => { - const output = parseReviewOutput("not json"); - expect(output.result).toBe("failed"); - expect(output.error).toBeDefined(); - }); - - it("returns failed on empty output", () => { - const output = parseReviewOutput(""); - expect(output.result).toBe("failed"); - }); - - it("extracts from result envelope", () => { - const envelope = JSON.stringify({ - type: "result", - subtype: "success", - is_error: false, - structured_output: { - result: "approved", - feedback: "All good", - issues: [], - }, - }); - const output = parseReviewOutput(envelope); - expect(output.result).toBe("approved"); - }); -}); - -describe("REVIEW_SCHEMA", () => { - it("is valid JSON", () => { - expect(() => JSON.parse(REVIEW_SCHEMA)).not.toThrow(); - }); -}); diff --git a/src/sandbox/agent-runner.ts b/src/sandbox/agent-runner.ts deleted file mode 100644 index b8a0b39..0000000 --- a/src/sandbox/agent-runner.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { z } from "zod"; - -const agentOutputSchema = z.object({ - result: z.enum(["implemented", "clarification_needed", "failed"]), - summary: z.string().optional(), - questions: z.array(z.string()).optional(), - error: z.string().optional(), -}); - -export type AgentOutput = z.infer; - -export const AGENT_SCHEMA = JSON.stringify({ - type: "object", - properties: { - result: { - type: "string", - enum: ["implemented", "clarification_needed", "failed"], - }, - summary: { type: "string" }, - questions: { type: "array", items: { type: "string" } }, - error: { type: "string" }, - }, - required: ["result"], -}); - -export function parseAgentOutput(raw: string): AgentOutput { - // Empty — treat as failure - if (!raw.trim()) { - return { result: "failed", error: "Agent produced no output" }; - } - - // Try direct parse first (normal --output-format json) - try { - const direct = agentOutputSchema.safeParse(JSON.parse(raw)); - if (direct.success) return direct.data; - } catch { - // Not valid JSON — try extraction below - } - - // stream-json / result-envelope format: one JSON object per line — look for the result event - const lines = raw.split("\n").filter(Boolean); - for (let i = lines.length - 1; i >= 0; i--) { - try { - const event = JSON.parse(lines[i]); - - if (event.type === "result") { - // --json-schema puts validated output in structured_output - if (event.structured_output != null) { - const parsed = agentOutputSchema.safeParse(event.structured_output); - if (parsed.success) return parsed.data; - } - - // Fallback: try event.result as JSON - if (typeof event.result === "string") { - try { - const parsed = agentOutputSchema.safeParse(JSON.parse(event.result)); - if (parsed.success) return parsed.data; - } catch { - // event.result is not valid JSON - } - } - - // Agent completed but structured_output was missing/invalid. - // Infer from the envelope status as last resort. - if (event.subtype === "success" && !event.is_error) { - return { - result: "implemented", - summary: typeof event.result === "string" - ? event.result.trim().slice(0, 500) - : undefined, - }; - } - - return { - result: "failed", - error: typeof event.result === "string" - ? event.result.trim().slice(0, 500) - : "Agent returned non-structured result", - }; - } - - // Also check if the line itself matches our schema - const direct = agentOutputSchema.safeParse(event); - if (direct.success) return direct.data; - } catch { - // Not valid JSON, try next line - } - } - - // Fallback: extract individual JSON objects from mixed text - const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); - for (const [candidate] of objects) { - try { - const result = agentOutputSchema.safeParse(JSON.parse(candidate)); - if (result.success) return result.data; - } catch { - // Not valid JSON, try next candidate - } - } - - return { - result: "failed", - error: `Agent output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, - }; -} - -// --- Research Status Parser --- - -export type ResearchStatus = "completed" | "clarification_needed" | "failed"; - -export interface ResearchResult { - status: ResearchStatus; - body: string; -} - -const VALID_RESEARCH_STATUSES: ResearchStatus[] = ["completed", "clarification_needed", "failed"]; - -export function parseResearchStatus(raw: string): ResearchResult { - const lines = raw.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]?.trim() ?? ""; - const match = line.match(/^STATUS:\s*([a-z_]+)/i); - if (!match) continue; - - const status = match[1].toLowerCase() as ResearchStatus; - if (VALID_RESEARCH_STATUSES.includes(status)) { - const body = lines.slice(i + 1).join("\n").trim(); - return { status, body }; - } - } - - return { status: "failed", body: raw }; -} - -// --- Review Output Schema --- - -const reviewOutputSchema = z.object({ - result: z.enum(["approved", "failed"]), - feedback: z.string(), - issues: z.array(z.object({ - file: z.string(), - description: z.string(), - severity: z.enum(["critical", "suggestion"]), - })), - error: z.string().optional(), -}); - -export type ReviewOutput = z.infer; - -export const REVIEW_SCHEMA = JSON.stringify({ - type: "object", - properties: { - result: { - type: "string", - enum: ["approved", "failed"], - }, - feedback: { type: "string" }, - issues: { - type: "array", - items: { - type: "object", - properties: { - file: { type: "string" }, - description: { type: "string" }, - severity: { type: "string", enum: ["critical", "suggestion"] }, - }, - required: ["file", "description", "severity"], - }, - }, - error: { type: "string" }, - }, - required: ["result", "feedback", "issues"], -}); - -export function parseReviewOutput(raw: string): ReviewOutput { - if (!raw.trim()) { - return { result: "failed", feedback: "", issues: [], error: "Review agent produced no output" }; - } - - // Direct parse - try { - const direct = reviewOutputSchema.safeParse(JSON.parse(raw)); - if (direct.success) return direct.data; - } catch {} - - // Stream-json / result-envelope format - const lines = raw.split("\n").filter(Boolean); - for (let i = lines.length - 1; i >= 0; i--) { - try { - const event = JSON.parse(lines[i]); - - if (event.type === "result" && event.structured_output != null) { - const parsed = reviewOutputSchema.safeParse(event.structured_output); - if (parsed.success) return parsed.data; - } - - const direct = reviewOutputSchema.safeParse(event); - if (direct.success) return direct.data; - } catch {} - } - - // Fallback: extract JSON objects - const objects = raw.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); - for (const [candidate] of objects) { - try { - const result = reviewOutputSchema.safeParse(JSON.parse(candidate)); - if (result.success) return result.data; - } catch {} - } - - return { - result: "failed", - feedback: "", - issues: [], - error: `Review output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, - }; -} diff --git a/src/sandbox/agents/claude.test.ts b/src/sandbox/agents/claude.test.ts new file mode 100644 index 0000000..b8ecea9 --- /dev/null +++ b/src/sandbox/agents/claude.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi } from "vitest"; +import { ClaudeAgentAdapter } from "./claude.js"; +import { AGENT_SCHEMA, REVIEW_SCHEMA } from "./types.js"; + +const adapter = new ClaudeAgentAdapter(); + +describe("ClaudeAgentAdapter.parseAgentOutput", () => { + it("parses implemented result", () => { + const raw = JSON.stringify({ result: "implemented", summary: "done" }); + expect(adapter.parseAgentOutput(raw, null).result).toBe("implemented"); + }); + + it("parses clarification_needed result", () => { + const raw = JSON.stringify({ + result: "clarification_needed", + questions: ["What OAuth provider?", "Should we support SSO?"], + }); + const out = adapter.parseAgentOutput(raw, null); + expect(out.result).toBe("clarification_needed"); + expect(out.questions).toHaveLength(2); + }); + + it("parses failed result", () => { + const raw = JSON.stringify({ result: "failed", error: "Tests do not pass" }); + const out = adapter.parseAgentOutput(raw, null); + expect(out.result).toBe("failed"); + expect(out.error).toBe("Tests do not pass"); + }); + + it("returns failed on empty output", () => { + expect(adapter.parseAgentOutput("", null).result).toBe("failed"); + }); + + it("returns failed on garbage", () => { + const out = adapter.parseAgentOutput("not json at all", null); + expect(out.result).toBe("failed"); + expect(out.error).toContain("not structured JSON"); + }); + + it("returns failed on JSON missing result field", () => { + expect(adapter.parseAgentOutput(JSON.stringify({ summary: "oops" }), null).result).toBe("failed"); + }); + + it("parses structured_output from result envelope", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "freeform text", + structured_output: { result: "implemented", summary: "Renamed endpoint" }, + }); + expect(adapter.parseAgentOutput(envelope, null).summary).toBe("Renamed endpoint"); + }); + + it("falls back to event.result as JSON when structured_output is missing", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: JSON.stringify({ result: "clarification_needed", questions: ["Which DB?"] }), + }); + const out = adapter.parseAgentOutput(envelope, null); + expect(out.result).toBe("clarification_needed"); + expect(out.questions).toEqual(["Which DB?"]); + }); + + it("infers implemented when result envelope has success but text output", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 6404, + num_turns: 1, + result: "\n\nI kept the response as-is to match the acceptance criteria.\n", + }); + const out = adapter.parseAgentOutput(envelope, null); + expect(out.result).toBe("implemented"); + expect(out.summary).toContain("acceptance criteria"); + }); + + it("infers failed when result envelope has error status", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "error", + is_error: true, + result: "Agent crashed unexpectedly", + }); + const out = adapter.parseAgentOutput(envelope, null); + expect(out.result).toBe("failed"); + expect(out.error).toContain("crashed"); + }); + + it("ignores the structured argument (Claude embeds output in raw)", () => { + const raw = JSON.stringify({ result: "implemented", summary: "via raw" }); + expect(adapter.parseAgentOutput(raw, "ignored payload").summary).toBe("via raw"); + }); +}); + +describe("ClaudeAgentAdapter.parseResearchStatus", () => { + it("parses a STATUS line and returns the body", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + result: "STATUS: completed\n\nPlan body here", + }); + const r = adapter.parseResearchStatus(envelope, null); + expect(r.status).toBe("completed"); + expect(r.body).toBe("Plan body here"); + }); + + it("parses clarification_needed with numbered questions", () => { + const raw = "STATUS: clarification_needed\n\n1. What database?\n2. Which auth?"; + const r = adapter.parseResearchStatus(raw, null); + expect(r.status).toBe("clarification_needed"); + expect(r.body).toContain("What database?"); + }); + + it("parses failed status", () => { + expect(adapter.parseResearchStatus("STATUS: failed\n\nCould not access repository", null).status).toBe("failed"); + }); + + it("falls back to failed when no STATUS line is present", () => { + expect(adapter.parseResearchStatus("no status here", null).status).toBe("failed"); + }); + + it("handles STATUS line with extra whitespace", () => { + expect(adapter.parseResearchStatus(" STATUS: completed \n\nPlan here", null).status).toBe("completed"); + }); + + it("handles leading blank lines before STATUS", () => { + const r = adapter.parseResearchStatus("\n\nSTATUS: clarification_needed\n\n1. Which provider?", null); + expect(r.status).toBe("clarification_needed"); + expect(r.body).toContain("Which provider?"); + }); + + it("normalizes uppercase status values", () => { + expect(adapter.parseResearchStatus("STATUS: CLARIFICATION_NEEDED\n\n1. Which provider?", null).status) + .toBe("clarification_needed"); + }); +}); + +describe("ClaudeAgentAdapter.parseReviewOutput", () => { + it("parses approved with empty issues", () => { + const raw = JSON.stringify({ result: "approved", feedback: "looks good", issues: [] }); + expect(adapter.parseReviewOutput(raw, null).result).toBe("approved"); + }); + + it("parses approved with critical issues", () => { + const raw = JSON.stringify({ + result: "approved", + feedback: "Fixed several issues", + issues: [{ file: "src/foo.ts", description: "Fixed null check", severity: "critical" }], + }); + const out = adapter.parseReviewOutput(raw, null); + expect(out.issues).toHaveLength(1); + expect(out.issues[0].severity).toBe("critical"); + }); + + it("returns failed on empty input", () => { + expect(adapter.parseReviewOutput("", null).result).toBe("failed"); + }); + + it("returns failed on unparseable output", () => { + const out = adapter.parseReviewOutput("not json", null); + expect(out.result).toBe("failed"); + expect(out.error).toBeDefined(); + }); + + it("extracts from result envelope structured_output", () => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + structured_output: { result: "approved", feedback: "All good", issues: [] }, + }); + expect(adapter.parseReviewOutput(envelope, null).result).toBe("approved"); + }); +}); + +describe("schema constants", () => { + it("AGENT_SCHEMA is valid JSON", () => { + expect(() => JSON.parse(AGENT_SCHEMA)).not.toThrow(); + }); + it("REVIEW_SCHEMA is valid JSON", () => { + expect(() => JSON.parse(REVIEW_SCHEMA)).not.toThrow(); + }); +}); + +describe("ClaudeAgentAdapter.extractUsage", () => { + it("extracts cost_usd from a result envelope", () => { + const raw = JSON.stringify({ + type: "result", subtype: "success", + cost_usd: 0.42, duration_ms: 60_000, duration_api_ms: 30_000, num_turns: 3, + result: "ok", + }); + expect(adapter.extractUsage(raw, null)).toEqual({ + cost_usd: 0.42, + tokens: null, + duration_ms: 60_000, + duration_api_ms: 30_000, + num_turns: 3, + }); + }); + + it("returns null when no envelope is present", () => { + expect(adapter.extractUsage("not json", null)).toBeNull(); + }); +}); + +describe("ClaudeAgentAdapter.buildPhaseScript", () => { + it("research phase emits a script that sources agent-env.sh and invokes claude", () => { + const paths = adapter.artifactPaths("research"); + const s = adapter.buildPhaseScript({ phase: "research", model: "claude-opus-4-6", paths }); + expect(s).toContain("#!/bin/bash"); + expect(s).toContain("claude"); + expect(s).toContain("--model 'claude-opus-4-6'"); + expect(s).toContain("--output-format json"); + expect(s).toContain("/tmp/research-requirements.md"); + expect(s).toContain("/tmp/research-stdout.txt"); + expect(s).toContain("/tmp/research-stderr.txt"); + expect(s).toContain("/tmp/research-done"); + expect(s).not.toContain("--json-schema"); + }); + + it("impl phase includes --json-schema when supplied", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ + phase: "impl", + model: "claude-opus-4-6", + paths, + jsonSchema: '{"type":"object"}', + }); + expect(s).toContain("--json-schema"); + expect(s).toContain("/tmp/impl-requirements.md"); + expect(s).toContain("/tmp/impl-done"); + }); + + it("review phase includes --json-schema when supplied", () => { + const paths = adapter.artifactPaths("review"); + const s = adapter.buildPhaseScript({ + phase: "review", + model: "claude-opus-4-6", + paths, + jsonSchema: '{"type":"object"}', + }); + expect(s).toContain("--json-schema"); + expect(s).toContain("/tmp/review-requirements.md"); + expect(s).toContain("/tmp/review-done"); + }); + + it("includes cleanup and sentinel touch in correct order", () => { + const paths = adapter.artifactPaths("research"); + const s = adapter.buildPhaseScript({ phase: "research", model: "claude-opus-4-6", paths }); + expect(s).toContain("rm -rf .claude/"); + expect(s).toContain("touch /tmp/research-done"); + const cleanupIdx = s.indexOf("rm -f /tmp/research-done /tmp/research-stdout.txt /tmp/research-stderr.txt"); + const claudeIdx = s.indexOf("| claude"); + expect(cleanupIdx).toBeGreaterThan(-1); + expect(cleanupIdx).toBeLessThan(claudeIdx); + }); + + it("escapes single quotes in json schema", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ + phase: "impl", + model: "claude-opus-4-6", + paths, + jsonSchema: `{"type":"object","desc":"it's"}`, + }); + // Schema appears inside a single-quoted shell string; raw apostrophe must + // be escaped via the '\'' sequence to avoid closing the quote prematurely. + expect(s).not.toContain("it's\"}"); + expect(s).toContain("it'\\''s"); + }); +}); + +describe("ClaudeAgentAdapter.artifactPaths", () => { + it("returns Claude paths with structuredOutput=null", () => { + expect(adapter.artifactPaths("research")).toEqual({ + wrapper: "/tmp/research-wrapper.sh", + input: "/tmp/research-requirements.md", + stdout: "/tmp/research-stdout.txt", + stderr: "/tmp/research-stderr.txt", + sentinel: "/tmp/research-done", + structuredOutput: null, + }); + }); +}); + +describe("ClaudeAgentAdapter.setCommitGuard", () => { + it("upserts the Stop hook when enabled and writes commit-guard.sh", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const writeFiles = vi.fn().mockResolvedValue(undefined); + const sandbox = { runCommand, writeFiles } as any; + + await adapter.setCommitGuard(sandbox, true); + + const calls = runCommand.mock.calls; + expect(calls.some(([cmd, args]) => cmd === "bash" && args[1].includes("commit-guard.sh"))).toBe(true); + const mergeCall = calls.find(([cmd, args]) => + cmd === "node" && args[1] === "-e" && args[2].includes('"commitGuard":"enable"'), + ); + expect(mergeCall).toBeDefined(); + }); + + it("disables by writing commitGuard=disable directive", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandbox = { runCommand, writeFiles: vi.fn() } as any; + + await adapter.setCommitGuard(sandbox, false); + + const mergeCall = runCommand.mock.calls.find(([cmd, args]) => + cmd === "node" && typeof args[2] === "string" && args[2].includes('"commitGuard":"disable"'), + ); + expect(mergeCall).toBeDefined(); + }); +}); diff --git a/src/sandbox/agents/claude.ts b/src/sandbox/agents/claude.ts new file mode 100644 index 0000000..f0bd077 --- /dev/null +++ b/src/sandbox/agents/claude.ts @@ -0,0 +1,334 @@ +import type { + AgentAdapter, AgentOutput, ConfigureOpts, PhaseArtifactPaths, PhaseKind, + PhaseScriptOpts, PhaseUsage, ResearchResult, ReviewOutput, RunnableSandbox, +} from "./types.js"; +import { agentOutputSchema, reviewOutputSchema } from "./types.js"; +import { installSkillsToAgentsDir } from "./shared.js"; +import { ARTHUR_TRACER_PY_BASE64 } from "../arthur-tracer.js"; + +const ARTHUR_HOOK_EVENTS: ReadonlyArray = [ + ["UserPromptSubmit", "user_prompt_submit"], + ["PreToolUse", "pre_tool"], + ["PostToolUse", "post_tool"], + ["PostToolUseFailure", "post_tool_failure"], + ["Stop", "stop"], +]; + +export class ClaudeAgentAdapter implements AgentAdapter { + readonly kind = "claude" as const; + + async install(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("npm", ["install", "-g", "@anthropic-ai/claude-code"]); + // Skip interactive onboarding + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, + ]); + } + + async configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise { + if (!opts.anthropicApiKey && !opts.claudeCodeOauthToken) { + throw new Error("ClaudeAgentAdapter.configure requires anthropicApiKey or claudeCodeOauthToken"); + } + const envLines: string[] = []; + if (opts.claudeCodeOauthToken) { + envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${shellQuote(opts.claudeCodeOauthToken)}`); + } else if (opts.anthropicApiKey) { + envLines.push(`export ANTHROPIC_API_KEY=${shellQuote(opts.anthropicApiKey)}`); + } + await sandbox.writeFiles([ + { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, + ]); + await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); + + // Skills: installer writes to ~/.claude/skills and ~/.agents/skills directly. + await installSkillsToAgentsDir(sandbox); + + // Arthur tracer (no-op without config) + if (opts.arthur) { + await this.installArthurTracer(sandbox, opts.arthur); + } + } + + async setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise { + // 1) Drop the guard script (idempotent) + await sandbox.runCommand("bash", [ + "-c", + [ + "mkdir -p ~/.claude", + "cat > ~/.claude/commit-guard.sh << 'SCRIPT'", + "#!/bin/bash", + "input=$(cat)", + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, + `if [ -n "$changes" ]; then`, + ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, + " exit 2", + "fi", + "SCRIPT", + "chmod +x ~/.claude/commit-guard.sh", + ].join("\n"), + ]); + + // 2) Toggle the Stop hook entry via merge-aware settings.json writer + await this.mergeSettings(sandbox, { commitGuard: enabled ? "enable" : "disable" }); + } + + buildPhaseScript(opts: PhaseScriptOpts): string { + const { paths, jsonSchema, model, phase } = opts; + let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions --output-format json`; + if (jsonSchema) { + const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); + claudeFlags += ` --json-schema '${escapedSchema}'`; + } + return `#!/bin/bash + +# --- Cleanup stale files from prior runs --- +rm -f ${paths.sentinel} ${paths.stdout} ${paths.stderr} + +# --- Source auth env vars --- +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +# --- Phase: ${phase} --- +cat ${paths.input} | claude \\ + ${claudeFlags} \\ + > ${paths.stdout} 2>${paths.stderr}; echo $? > /tmp/${phase}-exit-code || true + +# --- Cleanup --- +cd /vercel/sandbox +rm -rf .claude/ +git checkout -- .claude/ 2>/dev/null || true + +# --- Signal completion --- +touch ${paths.sentinel} +`; + } + + artifactPaths(phase: PhaseKind): PhaseArtifactPaths { + return { + wrapper: `/tmp/${phase}-wrapper.sh`, + input: `/tmp/${phase}-requirements.md`, + stdout: `/tmp/${phase}-stdout.txt`, + stderr: `/tmp/${phase}-stderr.txt`, + sentinel: `/tmp/${phase}-done`, + structuredOutput: null, + }; + } + + parseAgentOutput(raw: string, _structured: string | null): AgentOutput { + if (!raw.trim()) return { result: "failed", error: "Agent produced no output" }; + + try { + const direct = agentOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch { /* not direct JSON */ } + + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + if (event.type === "result") { + if (event.structured_output != null) { + const parsed = agentOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + if (typeof event.result === "string") { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(event.result)); + if (parsed.success) return parsed.data; + } catch { /* not JSON */ } + } + if (event.subtype === "success" && !event.is_error) { + return { + result: "implemented", + summary: typeof event.result === "string" ? event.result.trim().slice(0, 500) : undefined, + }; + } + return { + result: "failed", + error: typeof event.result === "string" ? event.result.trim().slice(0, 500) : "Agent returned non-structured result", + }; + } + const direct = agentOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch { /* try next line */ } + } + + return { result: "failed", error: `Agent output was not structured JSON. Output starts with: ${raw.slice(0, 500)}` }; + } + + parseReviewOutput(raw: string, _structured: string | null): ReviewOutput { + if (!raw.trim()) { + return { result: "failed", feedback: "", issues: [], error: "Review agent produced no output" }; + } + try { + const direct = reviewOutputSchema.safeParse(JSON.parse(raw)); + if (direct.success) return direct.data; + } catch { /* not direct JSON */ } + + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]); + if (event.type === "result" && event.structured_output != null) { + const parsed = reviewOutputSchema.safeParse(event.structured_output); + if (parsed.success) return parsed.data; + } + const direct = reviewOutputSchema.safeParse(event); + if (direct.success) return direct.data; + } catch { /* try next */ } + } + + return { + result: "failed", feedback: "", issues: [], + error: `Review output was not structured JSON. Output starts with: ${raw.slice(0, 500)}`, + }; + } + + parseResearchStatus(raw: string, _structured: string | null): ResearchResult { + const text = unwrapResearchEnvelope(raw); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim() ?? ""; + const m = line.match(/^STATUS:\s*([a-z_]+)/i); + if (!m) continue; + const status = m[1].toLowerCase(); + if (status === "completed" || status === "clarification_needed" || status === "failed") { + return { status, body: lines.slice(i + 1).join("\n").trim() }; + } + } + return { status: "failed", body: text }; + } + + extractUsage(raw: string, _structured: string | null): PhaseUsage | null { + if (!raw.trim()) return null; + const envelope = findResultEnvelope(raw); + if (!envelope) return null; + const cost = + typeof envelope.cost_usd === "number" ? envelope.cost_usd + : typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd + : null; + return { + cost_usd: cost, + tokens: null, + duration_ms: typeof envelope.duration_ms === "number" ? envelope.duration_ms : 0, + duration_api_ms: typeof envelope.duration_api_ms === "number" ? envelope.duration_api_ms : 0, + num_turns: typeof envelope.num_turns === "number" ? envelope.num_turns : 0, + }; + } + + // --- private --- + + private async installArthurTracer( + sandbox: RunnableSandbox, + arthur: NonNullable, + ): Promise { + const { logger } = await import("../../lib/logger.js"); + logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId, agent: this.kind }, "agent_install_arthur_started"); + + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", + ]); + if (pip.exitCode !== 0) { + logger.warn({}, "arthur_pip_install_failed"); + return; + } + + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([{ path: "/tmp/arthur-tracer.py", content: tracerBytes }]); + const mvTracer = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py", + ]); + if (mvTracer.exitCode !== 0) { + logger.warn({}, "arthur_tracer_install_failed"); + return; + } + + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, + null, 2, + ); + await sandbox.writeFiles([{ path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json", + ]); + + await this.mergeSettings(sandbox, { arthur: "install" }); + logger.info({ agent: this.kind }, "agent_install_arthur_complete"); + } + + /** Merge-aware writer for ~/.claude/settings.json. */ + private async mergeSettings( + sandbox: RunnableSandbox, + opts: { commitGuard?: "enable" | "disable"; arthur?: "install" }, + ): Promise { + const arthurEvents = JSON.stringify(ARTHUR_HOOK_EVENTS); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const arthurEvents = ${arthurEvents}; + const home = process.env.HOME; + const settingsPath = path.join(home, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsertHook = (event, matcher, command) => { + const existing = s.hooks[event] || []; + const has = existing.some(e => (e && Array.isArray(e.hooks) ? e.hooks : []).some(h => h && h.command === command)); + if (!has) existing.push({ matcher, hooks: [{ type: 'command', command }] }); + s.hooks[event] = existing; + }; + const removeHook = (event, predicate) => { + const existing = s.hooks[event] || []; + s.hooks[event] = existing + .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !predicate(h.command || '')) })) + .filter(e => (e.hooks || []).length > 0); + }; + + if (opts.commitGuard === 'enable') upsertHook('Stop', '', 'bash ~/.claude/commit-guard.sh'); + else if (opts.commitGuard === 'disable') removeHook('Stop', c => c.includes('commit-guard.sh')); + + if (opts.arthur === 'install') { + for (const [event, arg] of arthurEvents) { + upsertHook(event, '', 'python3 "$HOME/.claude/hooks/claude_code_tracer.py" ' + arg); + } + } + fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); + `; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); + } +} + +// --- module-private helpers --- + +function shellQuote(val: string): string { + return `'${val.replace(/'/g, "'\\''")}'`; +} + +function findResultEnvelope(raw: string): Record | null { + try { + const obj = JSON.parse(raw); + if (obj && typeof obj === "object" && (obj as any).type === "result") return obj as Record; + } catch { /* not single JSON */ } + const lines = raw.split("\n").filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const obj = JSON.parse(lines[i]); + if (obj && typeof obj === "object" && (obj as any).type === "result") return obj as Record; + } catch { /* try next */ } + } + return null; +} + +function unwrapResearchEnvelope(raw: string): string { + if (!raw.trim()) return raw; + const env = findResultEnvelope(raw); + if (!env) return raw; + return typeof env.result === "string" ? env.result : raw; +} diff --git a/src/sandbox/agents/codex.test.ts b/src/sandbox/agents/codex.test.ts new file mode 100644 index 0000000..8b7d447 --- /dev/null +++ b/src/sandbox/agents/codex.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi } from "vitest"; +import { CodexAgentAdapter } from "./codex.js"; + +const adapter = new CodexAgentAdapter(); + +describe("CodexAgentAdapter.parseAgentOutput", () => { + it("prefers structured JSON when valid", () => { + const structured = JSON.stringify({ result: "implemented", summary: "ok" }); + const out = adapter.parseAgentOutput("ignored ndjson", structured); + expect(out.result).toBe("implemented"); + expect(out.summary).toBe("ok"); + }); + + it("falls back to NDJSON item.completed when structured is missing", () => { + const ndjson = [ + JSON.stringify({ type: "thread.started", thread_id: "t" }), + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: '{"result":"implemented","summary":"foo"}' }, + }), + ].join("\n"); + const out = adapter.parseAgentOutput(ndjson, null); + expect(out.result).toBe("implemented"); + expect(out.summary).toBe("foo"); + }); + + it("ignores non-agent_message item.completed events (e.g. tool-call output)", () => { + // A function_call_output item that happens to carry a `text` field with + // valid JSON must not be mistaken for the assistant's final message. + const ndjson = [ + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: '{"result":"implemented","summary":"real"}' }, + }), + JSON.stringify({ + type: "item.completed", + item: { type: "function_call_output", text: '{"result":"failed","error":"tool noise"}' }, + }), + ].join("\n"); + const out = adapter.parseAgentOutput(ndjson, null); + expect(out.result).toBe("implemented"); + expect(out.summary).toBe("real"); + }); + + it("returns failed when both sources are unparseable", () => { + expect(adapter.parseAgentOutput("not ndjson", null).result).toBe("failed"); + }); + + it("returns failed on empty input", () => { + const out = adapter.parseAgentOutput("", null); + expect(out.result).toBe("failed"); + expect(out.error).toContain("no output"); + }); +}); + +describe("CodexAgentAdapter.parseResearchStatus", () => { + it("reads STATUS line from structured (free-form text)", () => { + const r = adapter.parseResearchStatus("ndjson irrelevant", "STATUS: completed\n\nbody"); + expect(r.status).toBe("completed"); + expect(r.body).toBe("body"); + }); + + it("falls back to last item.completed text when structured is null", () => { + const ndjson = [ + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "STATUS: failed\n\nreason" } }), + ].join("\n"); + const r = adapter.parseResearchStatus(ndjson, null); + expect(r.status).toBe("failed"); + }); + + it("returns failed when no STATUS line is present", () => { + expect(adapter.parseResearchStatus("no status here", null).status).toBe("failed"); + }); +}); + +describe("CodexAgentAdapter.parseReviewOutput", () => { + it("parses approved with empty issues from structured", () => { + const structured = JSON.stringify({ result: "approved", feedback: "looks good", issues: [] }); + expect(adapter.parseReviewOutput("", structured).result).toBe("approved"); + }); + + it("returns failed on empty input", () => { + expect(adapter.parseReviewOutput("", null).result).toBe("failed"); + }); +}); + +describe("CodexAgentAdapter.extractUsage", () => { + it("sums usage across multiple turn.completed events", () => { + const ndjson = [ + JSON.stringify({ type: "turn.completed", usage: { input_tokens: 100, output_tokens: 200, cached_input_tokens: 10 } }), + JSON.stringify({ type: "turn.completed", usage: { input_tokens: 50, output_tokens: 75, cached_input_tokens: 5 } }), + ].join("\n"); + const u = adapter.extractUsage(ndjson, null); + expect(u).toMatchObject({ + cost_usd: null, + tokens: { input: 150, cached_input: 15, output: 275 }, + duration_api_ms: 0, + num_turns: 2, + }); + }); + + it("computes duration_ms from event timestamps when available", () => { + const t0 = "2026-04-27T10:00:00.000Z"; + const t1 = "2026-04-27T10:02:00.000Z"; + const ndjson = [ + JSON.stringify({ type: "thread.started", timestamp: t0 }), + JSON.stringify({ type: "turn.completed", timestamp: t1, usage: { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0 } }), + ].join("\n"); + const u = adapter.extractUsage(ndjson, null); + expect(u?.duration_ms).toBe(120_000); + }); + + it("returns null when no turn.completed event is present", () => { + expect(adapter.extractUsage("\n", null)).toBeNull(); + }); + + it("falls back to phase.duration synthetic event when events lack timestamps", () => { + const ndjson = [ + JSON.stringify({ type: "turn.completed", usage: { input_tokens: 10, output_tokens: 20, cached_input_tokens: 0 } }), + JSON.stringify({ type: "phase.duration", duration_ms: 90_000 }), + ].join("\n"); + const u = adapter.extractUsage(ndjson, null); + expect(u?.duration_ms).toBe(90_000); + }); + + it("prefers event-derived duration over wall-clock when both are present", () => { + const t0 = "2026-04-27T10:00:00.000Z"; + const t1 = "2026-04-27T10:01:00.000Z"; + const ndjson = [ + JSON.stringify({ type: "thread.started", timestamp: t0 }), + JSON.stringify({ type: "turn.completed", timestamp: t1, usage: { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0 } }), + JSON.stringify({ type: "phase.duration", duration_ms: 99_999 }), + ].join("\n"); + expect(adapter.extractUsage(ndjson, null)?.duration_ms).toBe(60_000); + }); +}); + +describe("CodexAgentAdapter.buildPhaseScript", () => { + it("research phase uses -o without --output-schema", () => { + const paths = adapter.artifactPaths("research"); + const s = adapter.buildPhaseScript({ phase: "research", model: "gpt-5-codex", paths }); + expect(s).toContain("codex exec"); + // We bypass Codex's inner sandbox because Vercel Sandbox (outer microVM) + // already provides isolation and blocks bwrap's user-namespace syscall. + expect(s).toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(s).not.toContain("--full-auto"); + expect(s).toContain("--skip-git-repo-check"); + expect(s).toContain("--json"); + expect(s).toContain("-o /tmp/research-result.json"); + expect(s).not.toContain("--output-schema"); + }); + + it("impl phase uses --output-schema with a quoted heredoc", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ + phase: "impl", + model: "gpt-5-codex", + paths, + jsonSchema: '{"type":"object"}', + }); + expect(s).toContain("--output-schema /tmp/impl-schema.json"); + expect(s).toContain("'SCHEMA_EOF'"); + }); + + it("schema body containing apostrophes passes through literally", () => { + const paths = adapter.artifactPaths("impl"); + const tricky = `{"description":"don't break","x":"a\`b$c"}`; + const s = adapter.buildPhaseScript({ phase: "impl", model: "gpt-5-codex", paths, jsonSchema: tricky }); + expect(s).toContain(tricky); + }); + + it("removes stale phase artifacts before invoking codex", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ phase: "impl", model: "gpt-5-codex", paths }); + expect(s).toContain(`rm -f ${paths.sentinel} ${paths.stdout} ${paths.stderr} ${paths.structuredOutput}`); + expect(s).toContain(`touch ${paths.sentinel}`); + }); + + it("emits a phase.duration NDJSON event after codex exits", () => { + const paths = adapter.artifactPaths("impl"); + const s = adapter.buildPhaseScript({ phase: "impl", model: "gpt-5-codex", paths }); + expect(s).toContain("START_MS=$(date +%s%3N)"); + expect(s).toContain("END_MS=$(date +%s%3N)"); + expect(s).toContain('\\"type\\":\\"phase.duration\\"'); + }); +}); + +describe("CodexAgentAdapter.artifactPaths", () => { + it("includes structuredOutput pointing at -o file", () => { + expect(adapter.artifactPaths("impl").structuredOutput).toBe("/tmp/impl-result.json"); + }); +}); + +describe("CodexAgentAdapter.configure", () => { + it("adds .codex/ to .git/info/exclude so the agent never sees session pollution", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const writeFiles = vi.fn().mockResolvedValue(undefined); + const sandbox = { runCommand, writeFiles } as any; + await adapter.configure(sandbox, { codexApiKey: "sk-test", model: "gpt-5-codex" }); + const excludeCall = runCommand.mock.calls.find( + ([cmd, args]) => + cmd === "bash" && + typeof args?.[1] === "string" && + args[1].includes(".git/info/exclude"), + ); + expect(excludeCall).toBeDefined(); + expect(excludeCall![1][1]).toContain(".codex/"); + }); +}); + +describe("CodexAgentAdapter.setCommitGuard", () => { + it("upserts the Stop hook with matcher/hooks shape when enabled", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandbox = { runCommand, writeFiles: vi.fn() } as any; + await adapter.setCommitGuard(sandbox, true); + const merge = runCommand.mock.calls.find(([cmd, args]) => + cmd === "node" && typeof args[2] === "string" && args[2].includes('"commitGuard":"enable"'), + ); + expect(merge).toBeDefined(); + // The merge script must wrap commands as { matcher, hooks: [{type,command}] } — + // not the flat { type, command } shape Codex would silently ignore. + expect(merge![1][2]).toContain("hooks: [{ type: 'command', command }]"); + }); + + it("writes a guard script that reads stop_hook_active and emits decision:block", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandbox = { runCommand, writeFiles: vi.fn() } as any; + await adapter.setCommitGuard(sandbox, true); + const writeScript = runCommand.mock.calls.find(([cmd, args]) => + cmd === "bash" && typeof args[1] === "string" && args[1].includes("commit-guard.sh"), + ); + expect(writeScript).toBeDefined(); + const body: string = writeScript![1][1]; + expect(body).toContain('"stop_hook_active":true'); + expect(body).toContain('"decision":"block"'); + // Must NOT use the wrong protocol (continue:false stops the hook, not Codex). + expect(body).not.toContain('"continue":false'); + }); +}); diff --git a/src/sandbox/agents/codex.ts b/src/sandbox/agents/codex.ts new file mode 100644 index 0000000..092d9ba --- /dev/null +++ b/src/sandbox/agents/codex.ts @@ -0,0 +1,413 @@ +import type { + AgentAdapter, AgentOutput, ConfigureOpts, PhaseArtifactPaths, PhaseKind, + PhaseScriptOpts, PhaseUsage, ResearchResult, ReviewOutput, RunnableSandbox, +} from "./types.js"; +import { agentOutputSchema, reviewOutputSchema } from "./types.js"; +import { installSkillsToAgentsDir } from "./shared.js"; +import { ARTHUR_TRACER_PY_BASE64 } from "../arthur-tracer.js"; + +const ARTHUR_HOOK_EVENTS: ReadonlyArray = [ + ["UserPromptSubmit", "user_prompt_submit"], + ["PreToolUse", "pre_tool"], + ["PostToolUse", "post_tool"], + ["Stop", "stop"], +]; + +export class CodexAgentAdapter implements AgentAdapter { + readonly kind = "codex" as const; + + async install(sandbox: RunnableSandbox): Promise { + await sandbox.runCommand("npm", ["install", "-g", "@openai/codex"]); + } + + async configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise { + if (!opts.codexApiKey && !opts.codexChatGptOauthToken) { + throw new Error("CodexAgentAdapter.configure requires codexApiKey or codexChatGptOauthToken"); + } + + // 1) auth env file. Codex CLI auto-reads OPENAI_API_KEY when no auth.json + // exists; we additionally run `codex login --with-api-key` below to + // populate ~/.codex/auth.json so subsequent invocations work without env. + const envLines: string[] = []; + if (opts.codexApiKey) envLines.push(`export OPENAI_API_KEY=${shellQuote(opts.codexApiKey)}`); + // Arthur tracer runs as a hook subprocess; expose its env so it picks up + // config without depending on the discovery file paths. + if (opts.arthur) { + envLines.push(`export GENAI_ENGINE_API_KEY=${shellQuote(opts.arthur.apiKey)}`); + envLines.push(`export GENAI_ENGINE_TASK_ID=${shellQuote(opts.arthur.taskId)}`); + envLines.push(`export GENAI_ENGINE_TRACE_ENDPOINT=${shellQuote(opts.arthur.endpoint)}`); + } + await sandbox.writeFiles([{ path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }]); + await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); + + // 2) ~/.codex/config.toml — model + sandbox profile + hooks feature flag + // (codex_hooks is gated; without it ~/.codex/hooks.json is ignored). + // sandbox_mode is danger-full-access because the OUTER Vercel Sandbox + // microVM already enforces isolation, and Codex's workspace-write mode + // shells out to bwrap which requires user-namespace creation that Vercel + // Sandbox blocks ("bwrap: No permissions to create a new namespace"). + const configToml = [ + `model = "${opts.model}"`, + `approval_policy = "never"`, + `sandbox_mode = "danger-full-access"`, + ``, + `[features]`, + `codex_hooks = true`, + ].join("\n") + "\n"; + await sandbox.writeFiles([{ path: "/tmp/config.toml", content: Buffer.from(configToml) }]); + await sandbox.runCommand("bash", ["-c", "mkdir -p $HOME/.codex && mv /tmp/config.toml $HOME/.codex/config.toml"]); + + // 3) Persist API-key auth to ~/.codex/auth.json (OAuth token path uses the + // ChatGPT-cached auth.json shape directly; for now only API-key login is + // automated — OAuth tokens are exported via env above). + if (opts.codexApiKey) { + await sandbox.runCommand("bash", [ + "-c", + `[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh; printenv OPENAI_API_KEY | codex login --with-api-key`, + ]); + } + + // 4) skills (~/.agents/skills via `--agent codex`) + await installSkillsToAgentsDir(sandbox); + + // 5) commit-guard script (idempotent) + await this.writeCommitGuardScript(sandbox); + + // 6) Hide Codex's per-cwd session dir from git status. Without this the + // agent sees `.codex/` as untracked, "fixes" it by adding the path to + // `.gitignore`, commits only that, and the implementation never runs — + // observed on AWT-641 ($14 of impl-phase tokens, PR diff = .gitignore). + // .git/info/exclude is local to this clone and never pushed. + await sandbox.runCommand("bash", [ + "-c", + `mkdir -p /vercel/sandbox/.git/info && grep -qxF '.codex/' /vercel/sandbox/.git/info/exclude 2>/dev/null || echo '.codex/' >> /vercel/sandbox/.git/info/exclude`, + ]); + + // 7) Arthur tracer. Re-uses the Claude Code tracer; Codex traces will be + // labeled as "claude-code" in Arthur until a dedicated Codex tracer ships. + if (opts.arthur) await this.installArthurTracer(sandbox, opts.arthur); + } + + async setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise { + await this.writeCommitGuardScript(sandbox); // idempotent + await this.mergeHooks(sandbox, { commitGuard: enabled ? "enable" : "disable" }); + } + + buildPhaseScript(opts: PhaseScriptOpts): string { + const { paths, jsonSchema, model, phase } = opts; + + // --dangerously-bypass-approvals-and-sandbox over --full-auto: --full-auto + // upgrades to workspace-write which uses bwrap. bwrap fails inside Vercel + // Sandbox because the microVM blocks user-namespace creation. The outer + // microVM already provides the isolation Codex's inner sandbox would. + const flags: string[] = [ + `--model "${model}"`, + `--dangerously-bypass-approvals-and-sandbox`, + `--skip-git-repo-check`, + `--json`, + `-o ${paths.structuredOutput}`, + ]; + + let schemaBlock = ""; + if (jsonSchema) { + // Quoted heredoc terminator ('SCHEMA_EOF') keeps the body literal — no + // shell expansion or escaping is needed for the JSON contents. + schemaBlock = [ + `cat > /tmp/${phase}-schema.json << 'SCHEMA_EOF'`, + jsonSchema, + "SCHEMA_EOF", + ].join("\n"); + flags.push(`--output-schema /tmp/${phase}-schema.json`); + } + + return `#!/bin/bash + +# --- Cleanup stale files --- +rm -f ${paths.sentinel} ${paths.stdout} ${paths.stderr} ${paths.structuredOutput} + +# --- Source auth env vars --- +[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh + +${schemaBlock} + +# --- Phase: ${phase} --- +# Record wall-clock duration as a fallback for usage reporting — Codex's +# NDJSON events do not carry a timestamp field that extractUsage can parse. +START_MS=$(date +%s%3N) +cat ${paths.input} | codex exec \\ + ${flags.join(" \\\n ")} \\ + - \\ + > ${paths.stdout} 2> ${paths.stderr}; echo $? > /tmp/${phase}-exit-code || true +END_MS=$(date +%s%3N) +echo "{\\"type\\":\\"phase.duration\\",\\"duration_ms\\":$((END_MS - START_MS))}" >> ${paths.stdout} + +# --- Cleanup --- +cd /vercel/sandbox +rm -rf .codex/ +git checkout -- .codex/ 2>/dev/null || true + +touch ${paths.sentinel} +`; + } + + artifactPaths(phase: PhaseKind): PhaseArtifactPaths { + return { + wrapper: `/tmp/${phase}-wrapper.sh`, + input: `/tmp/${phase}-requirements.md`, + stdout: `/tmp/${phase}-stdout.txt`, + stderr: `/tmp/${phase}-stderr.txt`, + sentinel: `/tmp/${phase}-done`, + structuredOutput: `/tmp/${phase}-result.json`, + }; + } + + parseAgentOutput(raw: string, structured: string | null): AgentOutput { + if (structured) { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(structured)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + const text = unwrapLastAgentMessage(raw); + if (text) { + try { + const parsed = agentOutputSchema.safeParse(JSON.parse(text)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + if (!raw.trim() && !structured) { + return { result: "failed", error: "Codex produced no output" }; + } + return { + result: "failed", + error: `Codex output unparseable. First 500: ${(structured ?? raw).slice(0, 500)}`, + }; + } + + parseReviewOutput(raw: string, structured: string | null): ReviewOutput { + if (structured) { + try { + const parsed = reviewOutputSchema.safeParse(JSON.parse(structured)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + const text = unwrapLastAgentMessage(raw); + if (text) { + try { + const parsed = reviewOutputSchema.safeParse(JSON.parse(text)); + if (parsed.success) return parsed.data; + } catch { /* fall through */ } + } + return { + result: "failed", feedback: "", issues: [], + error: `Codex review output unparseable. First 500: ${(structured ?? raw).slice(0, 500)}`, + }; + } + + parseResearchStatus(raw: string, structured: string | null): ResearchResult { + const text = (structured ?? unwrapLastAgentMessage(raw) ?? raw).trim(); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const m = (lines[i] ?? "").trim().match(/^STATUS:\s*([a-z_]+)/i); + if (!m) continue; + const status = m[1].toLowerCase(); + if (status === "completed" || status === "clarification_needed" || status === "failed") { + return { status, body: lines.slice(i + 1).join("\n").trim() }; + } + } + return { status: "failed", body: text }; + } + + extractUsage(raw: string, _structured: string | null): PhaseUsage | null { + if (!raw.trim()) return null; + let input = 0, cached = 0, output = 0, turns = 0; + let firstTs: number | null = null; + let lastTs: number | null = null; + let wallClockMs: number | null = null; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + const ts = parseEventTs(event); + if (ts != null) { + if (firstTs == null) firstTs = ts; + lastTs = ts; + } + if (event?.type === "turn.completed" && event.usage) { + input += numOr0(event.usage.input_tokens); + cached += numOr0(event.usage.cached_input_tokens); + output += numOr0(event.usage.output_tokens); + turns += 1; + } + // Synthetic event written by the wrapper script — see buildPhaseScript. + if (event?.type === "phase.duration" && typeof event.duration_ms === "number") { + wallClockMs = event.duration_ms; + } + } catch { /* ignore non-JSON lines */ } + } + if (turns === 0) return null; + const eventDurationMs = firstTs != null && lastTs != null && lastTs > firstTs ? lastTs - firstTs : 0; + return { + cost_usd: null, + tokens: { input, cached_input: cached, output }, + duration_ms: eventDurationMs > 0 ? eventDurationMs : (wallClockMs ?? 0), + duration_api_ms: 0, + num_turns: turns, + }; + } + + // --- private helpers --- + + private async writeCommitGuardScript(sandbox: RunnableSandbox): Promise { + // Codex's Stop hook protocol (verified against developers.openai.com/codex/hooks): + // - input on stdin includes `stop_hook_active: true` on re-entry + // - to FORCE Codex to keep working: print {"decision":"block","reason":"..."} + // to stdout, exit 0. (The "continue:false / stopReason" shape stops the + // hook itself, NOT Codex — wrong for this use case.) + await sandbox.runCommand("bash", [ + "-c", + [ + "mkdir -p ~/.codex/hooks", + "cat > ~/.codex/hooks/commit-guard.sh << 'SCRIPT'", + "#!/bin/bash", + "input=$(cat)", + `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, + `changes=$(git status --porcelain | grep -v '^.. \\.codex/' | grep -v '^?? \\.codex/')`, + `if [ -n "$changes" ]; then`, + ` printf '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}\\n'`, + " exit 0", + "fi", + "exit 0", + "SCRIPT", + "chmod +x ~/.codex/hooks/commit-guard.sh", + ].join("\n"), + ]); + } + + private async mergeHooks( + sandbox: RunnableSandbox, + opts: { commitGuard?: "enable" | "disable"; arthur?: "install" }, + ): Promise { + // Codex hooks.json shape (matches Claude's settings.json): + // { "hooks": { "Event": [ { "matcher": "...", "hooks": [{type,command}] } ] } } + const arthurEvents = JSON.stringify(ARTHUR_HOOK_EVENTS); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + const opts = ${JSON.stringify(opts)}; + const arthurEvents = ${arthurEvents}; + const home = process.env.HOME; + const cfgPath = path.join(home, '.codex', 'hooks.json'); + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + let s = {}; + try { s = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {} + s.hooks = s.hooks || {}; + + const upsert = (event, matcher, command) => { + const groups = s.hooks[event] || []; + const has = groups.some(g => Array.isArray(g?.hooks) && g.hooks.some(h => h?.command === command)); + if (!has) groups.push({ matcher, hooks: [{ type: 'command', command }] }); + s.hooks[event] = groups; + }; + const remove = (event, predicate) => { + const groups = s.hooks[event] || []; + s.hooks[event] = groups + .map(g => ({ ...g, hooks: (g?.hooks || []).filter(h => !predicate(h?.command || '')) })) + .filter(g => (g.hooks || []).length > 0); + }; + + if (opts.commitGuard === 'enable') upsert('Stop', '', 'bash $HOME/.codex/hooks/commit-guard.sh'); + else if (opts.commitGuard === 'disable') remove('Stop', c => c.includes('commit-guard.sh')); + + if (opts.arthur === 'install') { + for (const [event, arg] of arthurEvents) { + upsert(event, '', 'python3 "$HOME/.codex/hooks/claude_code_tracer.py" ' + arg); + } + } + fs.writeFileSync(cfgPath, JSON.stringify(s, null, 2)); + `; + await sandbox.runCommand("node", ["--input-type=module", "-e", script]); + } + + private async installArthurTracer( + sandbox: RunnableSandbox, + arthur: NonNullable, + ): Promise { + const { logger } = await import("../../lib/logger.js"); + logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId, agent: this.kind }, "agent_install_arthur_started"); + + const pip = await sandbox.runCommand("bash", [ + "-c", + "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", + ]); + if (pip.exitCode !== 0) { logger.warn({}, "arthur_pip_install_failed"); return; } + + const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); + await sandbox.writeFiles([{ path: "/tmp/arthur-tracer.py", content: tracerBytes }]); + const mvTracer = await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.codex/hooks && mv /tmp/arthur-tracer.py $HOME/.codex/hooks/claude_code_tracer.py && chmod +x $HOME/.codex/hooks/claude_code_tracer.py", + ]); + if (mvTracer.exitCode !== 0) { logger.warn({}, "arthur_tracer_install_failed"); return; } + + // The bundled tracer's discover_config() reads GENAI_ENGINE_* env vars + // first (wired via /tmp/agent-env.sh), then ~/.claude/arthur_config.json. + // Mirror the file at both ~/.claude and ~/.codex so file-discovery works + // regardless of how the hook subprocess is launched. + const configJson = JSON.stringify( + { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, null, 2, + ); + await sandbox.writeFiles([{ path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }]); + await sandbox.runCommand("bash", [ + "-c", + "mkdir -p $HOME/.claude $HOME/.codex && cp /tmp/arthur_config.json $HOME/.claude/arthur_config.json && mv /tmp/arthur_config.json $HOME/.codex/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json $HOME/.codex/arthur_config.json", + ]); + + await this.mergeHooks(sandbox, { arthur: "install" }); + logger.info({ agent: this.kind }, "agent_install_arthur_complete"); + } +} + +// --- module-private helpers --- + +function shellQuote(val: string): string { + return `'${val.replace(/'/g, "'\\''")}'`; +} + +function numOr0(x: unknown): number { return typeof x === "number" ? x : 0; } + +/** Walk Codex NDJSON in reverse for the last agent-message `item.completed` event. */ +function unwrapLastAgentMessage(raw: string): string | null { + if (!raw.trim()) return null; + const lines = raw.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line || !line.trim()) continue; + try { + const event = JSON.parse(line); + if (event?.type !== "item.completed" || !event.item) continue; + // Filter by item.type to avoid picking up tool-call results that also + // carry `text`. Codex emits `agent_message` for the assistant's reply. + if (event.item.type && event.item.type !== "agent_message") continue; + if (typeof event.item.text === "string") return event.item.text; + if (typeof event.item.content === "string") return event.item.content; + } catch { /* not JSON */ } + } + return null; +} + +/** Best-effort timestamp extraction from a Codex NDJSON event. Returns ms since epoch or null. */ +function parseEventTs(event: unknown): number | null { + if (!event || typeof event !== "object") return null; + const e = event as Record; + for (const key of ["timestamp", "ts", "time"]) { + const v = e[key]; + if (typeof v === "string") { + const n = Date.parse(v); + if (!Number.isNaN(n)) return n; + } else if (typeof v === "number") { + return v > 1e12 ? v : v * 1000; + } + } + return null; +} diff --git a/src/sandbox/agents/index.test.ts b/src/sandbox/agents/index.test.ts new file mode 100644 index 0000000..f199829 --- /dev/null +++ b/src/sandbox/agents/index.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { createAgentAdapter, parseAgentKindOverride } from "./index.js"; + +describe("createAgentAdapter", () => { + it("returns ClaudeAgentAdapter for kind=claude", () => { + const a = createAgentAdapter("claude"); + expect(a.kind).toBe("claude"); + }); + + it("returns CodexAgentAdapter for kind=codex", () => { + const a = createAgentAdapter("codex"); + expect(a.kind).toBe("codex"); + }); + + it("throws for unknown kinds (forces exhaustive switch updates)", () => { + // @ts-expect-error — runtime guard + expect(() => createAgentAdapter("bogus")).toThrow(); + }); +}); + +describe("parseAgentKindOverride", () => { + it("returns null for empty labels", () => { + expect(parseAgentKindOverride([])).toBeNull(); + }); + + it("returns null when no agent: label present", () => { + expect(parseAgentKindOverride(["bug", "frontend"])).toBeNull(); + }); + + it("recognizes agent:claude", () => { + expect(parseAgentKindOverride(["agent:claude"])).toBe("claude"); + }); + + it("recognizes agent:codex", () => { + expect(parseAgentKindOverride(["agent:codex"])).toBe("codex"); + }); + + it("is case-insensitive", () => { + expect(parseAgentKindOverride(["Agent:Codex"])).toBe("codex"); + expect(parseAgentKindOverride(["AGENT:CLAUDE"])).toBe("claude"); + }); + + it("returns null for unknown agent kinds", () => { + expect(parseAgentKindOverride(["agent:gemini"])).toBeNull(); + }); + + it("returns null when multiple distinct kinds are labeled (ambiguous → fall back to env)", () => { + expect( + parseAgentKindOverride(["agent:claude", "agent:codex"]), + ).toBeNull(); + }); + + it("collapses duplicate labels", () => { + expect( + parseAgentKindOverride(["agent:codex", "agent:codex"]), + ).toBe("codex"); + }); + + it("ignores labels with the prefix but trailing whitespace is stripped", () => { + expect(parseAgentKindOverride([" agent:claude "])).toBe("claude"); + }); +}); diff --git a/src/sandbox/agents/index.ts b/src/sandbox/agents/index.ts new file mode 100644 index 0000000..c6fa97e --- /dev/null +++ b/src/sandbox/agents/index.ts @@ -0,0 +1,39 @@ +import { ClaudeAgentAdapter } from "./claude.js"; +import { CodexAgentAdapter } from "./codex.js"; +import type { AgentAdapter } from "./types.js"; + +export type AgentKind = "claude" | "codex"; + +export function createAgentAdapter(kind: AgentKind): AgentAdapter { + switch (kind) { + case "claude": return new ClaudeAgentAdapter(); + case "codex": return new CodexAgentAdapter(); + default: { + const _exhaustive: never = kind; + throw new Error(`Unknown AGENT_KIND: ${_exhaustive}`); + } + } +} + +const AGENT_LABEL_PREFIX = "agent:"; + +/** + * Parse a per-ticket agent override from issue-tracker labels. Returns the + * AgentKind named by the first `agent:` label, or `null` if none/invalid. + * Conflicting labels (e.g. both `agent:claude` and `agent:codex`) collapse to + * `null` — caller falls back to the env default. + */ +export function parseAgentKindOverride(labels: readonly string[]): AgentKind | null { + const matches = new Set(); + for (const raw of labels) { + const lower = raw.trim().toLowerCase(); + if (!lower.startsWith(AGENT_LABEL_PREFIX)) continue; + matches.add(lower.slice(AGENT_LABEL_PREFIX.length)); + } + if (matches.size !== 1) return null; + const [only] = matches; + if (only === "claude" || only === "codex") return only; + return null; +} + +export type { AgentAdapter } from "./types.js"; diff --git a/src/sandbox/agents/pricing.test.ts b/src/sandbox/agents/pricing.test.ts new file mode 100644 index 0000000..9eab02c --- /dev/null +++ b/src/sandbox/agents/pricing.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../env.js", () => ({ + env: { + CODEX_PRICING_URL: "https://example.test/prices.json", + CODEX_PRICING_TTL_MS: 3_600_000, + }, +})); + +const SAMPLE = { + "gpt-5-codex": { + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 0.0000007, + }, +}; + +describe("fetchModelPrice", () => { + beforeEach(() => { vi.resetModules(); }); + + it("normalises LiteLLM JSON to TokenPrice", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, json: async () => SAMPLE, + })); + const { fetchModelPrice } = await import("./pricing.js"); + const p = await fetchModelPrice("gpt-5-codex"); + expect(p).toEqual({ input: 0.000003, cached_input: 0.0000007, output: 0.000015 }); + }); + + it("returns null on miss", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + const { fetchModelPrice } = await import("./pricing.js"); + expect(await fetchModelPrice("unknown")).toBeNull(); + }); + + it("returns null on fetch failure", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + const { fetchModelPrice } = await import("./pricing.js"); + expect(await fetchModelPrice("any")).toBeNull(); + }); + + it("caches successful responses within TTL (one fetch for two calls)", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => SAMPLE }); + vi.stubGlobal("fetch", fetchMock); + const { fetchModelPrice } = await import("./pricing.js"); + await fetchModelPrice("gpt-5-codex"); + await fetchModelPrice("gpt-5-codex"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/sandbox/agents/pricing.ts b/src/sandbox/agents/pricing.ts new file mode 100644 index 0000000..43403c7 --- /dev/null +++ b/src/sandbox/agents/pricing.ts @@ -0,0 +1,55 @@ +export interface TokenPrice { + input: number; + cached_input: number; + output: number; +} + +interface CacheEntry { + fetchedAt: number; + data: Record; +} +let cache: CacheEntry | null = null; + +interface LiteLLMEntry { + input_cost_per_token?: number; + output_cost_per_token?: number; + cache_read_input_token_cost?: number; +} + +async function loadAll(): Promise | null> { + const { env } = await import("../../../env.js"); + const ttl = env.CODEX_PRICING_TTL_MS; + if (cache && Date.now() - cache.fetchedAt < ttl) return cache.data; + + try { + const r = await fetch(env.CODEX_PRICING_URL); + if (!r.ok) return null; + const json = await r.json(); + const out: Record = {}; + for (const [name, entry] of Object.entries(json as Record)) { + if (typeof entry !== "object" || entry === null) continue; + const input = entry.input_cost_per_token; + const output = entry.output_cost_per_token; + if (typeof input !== "number" || typeof output !== "number") continue; + out[name] = { + input, + output, + cached_input: typeof entry.cache_read_input_token_cost === "number" + ? entry.cache_read_input_token_cost + : 0, + }; + } + cache = { fetchedAt: Date.now(), data: out }; + return out; + } catch { + return null; + } +} + +export async function fetchModelPrice(model: string): Promise { + const all = await loadAll(); + return all?.[model] ?? null; +} + +/** Test-only: clear the in-memory cache. */ +export function _resetPricingCache(): void { cache = null; } diff --git a/src/sandbox/agents/shared.test.ts b/src/sandbox/agents/shared.test.ts new file mode 100644 index 0000000..7af0716 --- /dev/null +++ b/src/sandbox/agents/shared.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { GLOBAL_SKILLS, installSkillsToAgentsDir } from "./shared.js"; + +describe("GLOBAL_SKILLS", () => { + it("contains the expected skill repos", () => { + const ids = GLOBAL_SKILLS.map((s) => `${s.repo}#${s.skill}`); + expect(ids).toEqual(["https://github.com/anthropics/skills#frontend-design"]); + }); +}); + +describe("installSkillsToAgentsDir", () => { + it("runs `npx skills add -g --agent claude-code codex --copy` for each entry", async () => { + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + const writeFiles = vi.fn().mockResolvedValue(undefined); + const sandbox = { runCommand, writeFiles } as any; + + await installSkillsToAgentsDir(sandbox); + + const calls = runCommand.mock.calls.filter((c) => c[0] === "npx"); + expect(calls).toHaveLength(GLOBAL_SKILLS.length); + for (const [_, args] of calls) { + expect(args).toContain("skills"); + expect(args).toContain("add"); + expect(args).toContain("-g"); + expect(args).toContain("--agent"); + // Both agent dirs populated in one pass — no symlinks needed. + expect(args).toContain("claude-code"); + expect(args).toContain("codex"); + expect(args).toContain("--copy"); + expect(args).not.toContain("--target"); + } + }); +}); diff --git a/src/sandbox/agents/shared.ts b/src/sandbox/agents/shared.ts new file mode 100644 index 0000000..ae67406 --- /dev/null +++ b/src/sandbox/agents/shared.ts @@ -0,0 +1,40 @@ +import type { RunnableSandbox } from "./types.js"; + +// `skills add --agent claude-code codex` populates BOTH agent dirs in one +// pass: ~/.claude/skills/ and ~/.agents/skills/. No symlinks +// needed — each agent reads from its own native path. +export const GLOBAL_SKILLS = [ + { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, +] as const; + +export async function installSkillsToAgentsDir(sandbox: RunnableSandbox): Promise { + for (const { repo, skill } of GLOBAL_SKILLS) { + const result = await sandbox.runCommand("npx", [ + "-y", "skills", "add", repo, + "--skill", skill, + "--yes", + "-g", + "--agent", "claude-code", "codex", + "--copy", + ]); + if (result.exitCode !== 0) { + const { logger } = await import("../../lib/logger.js"); + const stderr = await result.stderr().catch(() => ""); + logger.error( + { repo, skill, exitCode: result.exitCode, output: stderr.slice(0, 500) }, + "skill_install_failed", + ); + throw new Error(`Failed to install skill ${skill} from ${repo} (exit ${result.exitCode})`); + } + } +} + +/** Bash body for the commit-guard hook. The output protocol differs between agents, + * so each adapter wraps this differently. */ +export const COMMIT_GUARD_CHECK_SH = [ + "input=$(cat)", + // Skip when re-entered (set by Claude as stop_hook_active, by us as already_blocked for Codex) + `if echo "$input" | grep -q -E '"stop_hook_active":true|"already_blocked":true'; then exit 0; fi`, + // Ignore changes inside ~/.claude/ or ~/.codex/ inside the workspace + `changes=$(git status --porcelain | grep -v -E '^.. \\.(claude|codex)/' | grep -v -E '^\\?\\? \\.(claude|codex)/' || true)`, +].join("\n"); diff --git a/src/sandbox/agents/types.ts b/src/sandbox/agents/types.ts new file mode 100644 index 0000000..2fd14d8 --- /dev/null +++ b/src/sandbox/agents/types.ts @@ -0,0 +1,141 @@ +import type { Sandbox as SandboxType } from "@vercel/sandbox"; +import { z } from "zod"; + +export type PhaseKind = "research" | "impl" | "review"; + +type SandboxInstance = Awaited>; + +/** Minimal interface for sandbox objects that support runCommand and writeFiles. */ +export interface RunnableSandbox { + runCommand: SandboxInstance["runCommand"]; + writeFiles: SandboxInstance["writeFiles"]; +} + +// --- Schemas (moved from src/sandbox/agent-runner.ts) --- + +export const agentOutputSchema = z.object({ + result: z.enum(["implemented", "clarification_needed", "failed"]), + summary: z.string().nullish(), + questions: z.array(z.string()).nullish(), + error: z.string().nullish(), +}); +export type AgentOutput = z.infer; + +// OpenAI Structured Outputs strict mode (used by Codex --output-schema) requires +// `additionalProperties: false` on every object and every property listed in +// `required`. Optional fields are expressed as `["", "null"]` unions. +export const AGENT_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { type: "string", enum: ["implemented", "clarification_needed", "failed"] }, + summary: { type: ["string", "null"] }, + questions: { + anyOf: [ + { type: "array", items: { type: "string" } }, + { type: "null" }, + ], + }, + error: { type: ["string", "null"] }, + }, + required: ["result", "summary", "questions", "error"], + additionalProperties: false, +}); + +export const reviewOutputSchema = z.object({ + result: z.enum(["approved", "failed"]), + feedback: z.string(), + issues: z.array(z.object({ + file: z.string(), + description: z.string(), + severity: z.enum(["critical", "suggestion"]), + })), + error: z.string().nullish(), +}); +export type ReviewOutput = z.infer; + +export const REVIEW_SCHEMA = JSON.stringify({ + type: "object", + properties: { + result: { type: "string", enum: ["approved", "failed"] }, + feedback: { type: "string" }, + issues: { + type: "array", + items: { + type: "object", + properties: { + file: { type: "string" }, + description: { type: "string" }, + severity: { type: "string", enum: ["critical", "suggestion"] }, + }, + required: ["file", "description", "severity"], + additionalProperties: false, + }, + }, + error: { type: ["string", "null"] }, + }, + required: ["result", "feedback", "issues", "error"], + additionalProperties: false, +}); + +export type ResearchStatus = "completed" | "clarification_needed" | "failed"; +export interface ResearchResult { status: ResearchStatus; body: string; } + +// --- Usage (replaces shape in src/sandbox/usage.ts) --- + +export interface PhaseUsage { + /** Populated by Claude (CLI computes dollars itself). null for Codex (computed downstream from tokens). */ + cost_usd: number | null; + /** Populated by Codex from turn.completed. null for Claude. */ + tokens: { input: number; cached_input: number; output: number } | null; + duration_ms: number; + duration_api_ms: number; + num_turns: number; +} + +// --- Adapter contract --- + +export interface ArthurConfig { + apiKey: string; + taskId: string; + endpoint: string; +} + +export interface ConfigureOpts { + anthropicApiKey?: string; + claudeCodeOauthToken?: string; + codexApiKey?: string; + codexChatGptOauthToken?: string; + model: string; + arthur?: ArthurConfig; +} + +export interface PhaseArtifactPaths { + wrapper: string; + input: string; + stdout: string; + stderr: string; + sentinel: string; + /** Schema-validated JSON file (Codex --output-schema). null for Claude. */ + structuredOutput: string | null; +} + +export interface PhaseScriptOpts { + phase: PhaseKind; + model: string; + paths: PhaseArtifactPaths; + /** When set, the phase requests schema-validated structured output. */ + jsonSchema?: string; +} + +export interface AgentAdapter { + kind: "claude" | "codex"; + install(sandbox: RunnableSandbox): Promise; + configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise; + setCommitGuard(sandbox: RunnableSandbox, enabled: boolean): Promise; + buildPhaseScript(opts: PhaseScriptOpts): string; + artifactPaths(phase: PhaseKind): PhaseArtifactPaths; + parseAgentOutput(raw: string, structured: string | null): AgentOutput; + parseReviewOutput(raw: string, structured: string | null): ReviewOutput; + parseResearchStatus(raw: string, structured: string | null): ResearchResult; + extractUsage(raw: string, structured: string | null): PhaseUsage | null; +} diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index eeaad50..a42babd 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -4,7 +4,6 @@ const mockRunCommand = vi.fn(); const mockWriteFiles = vi.fn(); const mockStop = vi.fn(); const mockStdout = vi.fn(); -const mockReadFileToBuffer = vi.fn(); vi.mock("@vercel/sandbox", () => ({ Sandbox: { @@ -12,288 +11,102 @@ vi.mock("@vercel/sandbox", () => ({ sandboxId: "sbx-test-123", runCommand: mockRunCommand, writeFiles: mockWriteFiles, - readFileToBuffer: mockReadFileToBuffer, stop: mockStop, })), }, })); -import { SandboxManager, configureStopHookInSandbox } from "./manager.js"; - -describe("SandboxManager", () => { +import { SandboxManager } from "./manager.js"; +import type { AgentAdapter, ConfigureOpts } from "./agents/types.js"; + +const makeFakeAgent = (): AgentAdapter & { calls: any[] } => { + const calls: any[] = []; + return { + kind: "claude", + install: vi.fn(async () => { calls.push({ op: "install" }); }), + configure: vi.fn(async (_, opts: ConfigureOpts) => { calls.push({ op: "configure", opts }); }), + setCommitGuard: vi.fn(async (_s, enabled) => { calls.push({ op: "guard", enabled }); }), + buildPhaseScript: () => "#!/bin/bash\necho noop", + artifactPaths: () => ({ wrapper: "", input: "", stdout: "", stderr: "", sentinel: "", structuredOutput: null }), + parseAgentOutput: () => ({ result: "implemented" }), + parseReviewOutput: () => ({ result: "approved", feedback: "", issues: [] }), + parseResearchStatus: () => ({ status: "completed", body: "" }), + extractUsage: () => null, + calls, + } as any; +}; + +describe("SandboxManager.provision", () => { beforeEach(() => { vi.clearAllMocks(); - mockRunCommand.mockResolvedValue({ - exitCode: 0, - stdout: mockStdout, - }); + mockRunCommand.mockResolvedValue({ exitCode: 0, stdout: mockStdout }); mockStdout.mockResolvedValue(""); mockWriteFiles.mockResolvedValue(undefined); - mockStop.mockResolvedValue(undefined); }); - it("provisions sandbox with git source and env vars", async () => { + const baseConfig = { + kind: "github" as const, + token: "ghp_test", + repoPath: "test-org/test-repo", + host: "https://github.com", + jobTimeoutMs: 1_800_000, + commitAuthor: "ai-workflow-blazity", + commitEmail: "bot@blazity.com", + }; + + it("creates the sandbox with a git source pointed at the branch", async () => { const { Sandbox } = await import("@vercel/sandbox"); - - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - const sandbox = await manager.provision("feat/test-branch"); - + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); expect(Sandbox.create).toHaveBeenCalledWith( expect.objectContaining({ - source: expect.objectContaining({ - type: "git", - revision: "feat/test-branch", - }), - env: expect.objectContaining({ - ANTHROPIC_API_KEY: "sk-ant-test", - }), + source: expect.objectContaining({ type: "git", revision: "feat/test-branch" }), + runtime: "node24", }), ); - expect(sandbox.sandboxId).toBe("sbx-test-123"); - }); - - it("writes agent-env.sh with auth credentials during provision", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - await manager.provision("feat/test-branch"); - - // writeFiles should be called once — to persist auth env vars to /tmp/agent-env.sh - expect(mockWriteFiles).toHaveBeenCalledTimes(1); - const [[files]] = mockWriteFiles.mock.calls; - expect(files).toHaveLength(1); - expect(files[0].path).toBe("/tmp/agent-env.sh"); - const content = Buffer.from(files[0].content).toString(); - expect(content).toContain("ANTHROPIC_API_KEY"); - expect(content).toContain("sk-ant-test"); - expect(content).not.toContain("CLAUDE_MODEL"); - }); - - it("writes CLAUDE_CODE_OAUTH_TOKEN when OAuth token is provided", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - claudeCodeOauthToken: "oauth-token-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - await manager.provision("feat/test-branch"); - - const [[files]] = mockWriteFiles.mock.calls; - const content = Buffer.from(files[0].content).toString(); - expect(content).toContain("CLAUDE_CODE_OAUTH_TOKEN"); - expect(content).toContain("oauth-token-test"); - expect(content).not.toContain("ANTHROPIC_API_KEY"); - }); - - it("enabling the stop hook runs a node merge script that adds commit-guard", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - const sandbox = await manager.provision("feat/test-branch"); - mockRunCommand.mockClear(); - - await manager.configureStopHook(sandbox, true); - - const mergeCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "node" && - Array.isArray(c[1]) && - c[1][0] === "--input-type=module" && - c[1][1] === "-e" && - typeof c[1][2] === "string" && - c[1][2].includes("commit-guard.sh") && - c[1][2].includes('"commitGuard":"enable"'), - ); - expect(mergeCall).toBeDefined(); }); - it("disabling the stop hook runs a node merge script with commitGuard=disable", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - const sandbox = await manager.provision("feat/test-branch"); - mockRunCommand.mockClear(); - - await manager.configureStopHook(sandbox, false); - - const mergeCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "node" && - Array.isArray(c[1]) && - c[1][0] === "--input-type=module" && - c[1][1] === "-e" && - typeof c[1][2] === "string" && - c[1][2].includes('"commitGuard":"disable"'), + it("sets git identity to commitAuthor / commitEmail", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); + const idCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("git config user.name"), ); - expect(mergeCall).toBeDefined(); + expect(idCall).toBeDefined(); + expect(idCall![1][1]).toContain("ai-workflow-blazity"); + expect(idCall![1][1]).toContain("bot@blazity.com"); }); - it("configureStopHookInSandbox works with any sandbox-like object", async () => { - const fakeSandbox = { runCommand: mockRunCommand }; - mockRunCommand.mockClear(); - - await configureStopHookInSandbox(fakeSandbox as any, true); - - const mergeCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "node" && - Array.isArray(c[1]) && - c[1][0] === "--input-type=module" && - c[1][1] === "-e" && - typeof c[1][2] === "string" && - c[1][2].includes('"commitGuard":"enable"'), + it("captures pre-agent HEAD SHA for the push step", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }); + const shaCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("/tmp/.pre-agent-sha"), ); - expect(mergeCall).toBeDefined(); + expect(shaCall).toBeDefined(); }); - it("installs Arthur tracer when config.arthur is set", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", + it("calls agent.install then agent.configure with the supplied opts", async () => { + const agent = makeFakeAgent(); + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", agent, { anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - arthur: { - apiKey: "test-key", - taskId: "00000000-0000-4000-8000-000000000000", - endpoint: "https://example.ngrok.app/api/v1/traces", - }, + model: "claude-opus-4-6", }); - - await manager.provision("feat/test-branch"); - - const pipCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "bash" && - typeof c[1]?.[1] === "string" && - c[1][1].includes("ensurepip") && - c[1][1].includes("python3 -m pip install") && - c[1][1].includes("opentelemetry-sdk") && - c[1][1].includes("opentelemetry-exporter-otlp-proto-http"), + const ops = (agent as any).calls.map((c: any) => c.op); + expect(ops).toEqual(["install", "configure"]); + expect((agent as any).calls[1].opts).toEqual( + expect.objectContaining({ anthropicApiKey: "sk-ant-test", model: "claude-opus-4-6" }), ); - expect(pipCall).toBeDefined(); - - const arthurMergeCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "node" && - Array.isArray(c[1]) && - c[1][0] === "--input-type=module" && - c[1][1] === "-e" && - typeof c[1][2] === "string" && - c[1][2].includes('"arthur":"install"') && - c[1][2].includes("user_prompt_submit") && - c[1][2].includes("pre_tool") && - c[1][2].includes("post_tool") && - c[1][2].includes("post_tool_failure"), - ); - expect(arthurMergeCall).toBeDefined(); }); - it("skips Arthur install when config.arthur is undefined", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - }); - - await manager.provision("feat/test-branch"); - - const pipCall = mockRunCommand.mock.calls.find( - (c: any[]) => - c[0] === "bash" && - typeof c[1]?.[1] === "string" && - c[1][1].includes("python3 -m pip install"), + it("fetches and merges mergeBase when supplied", async () => { + const manager = new SandboxManager(baseConfig); + await manager.provision("feat/test-branch", makeFakeAgent(), { model: "any", anthropicApiKey: "k" }, "main"); + const fetchCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args[1] === "string" && args[1].includes("git fetch"), ); - expect(pipCall).toBeUndefined(); - }); - - it("Arthur install writes arthur_config.json and the tracer script", async () => { - const manager = new SandboxManager({ - kind: "github", - token: "ghp_test", - repoPath: "test-org/test-repo", - host: "https://github.com", - anthropicApiKey: "sk-ant-test", - claudeModel: "claude-opus-4-6", - commitAuthor: "ai-workflow-blazity", - commitEmail: "bot@blazity.com", - jobTimeoutMs: 1_800_000, - arthur: { - apiKey: "test-key", - taskId: "00000000-0000-4000-8000-000000000000", - endpoint: "https://example.ngrok.app/api/v1/traces", - }, - }); - - await manager.provision("feat/test-branch"); - - // Every writeFiles call passes an array of { path, content }. Flatten them. - const written = mockWriteFiles.mock.calls.flatMap(([files]: any[]) => files); - const tracerFile = written.find((f: any) => f.path.endsWith("arthur-tracer.py")); - expect(tracerFile).toBeDefined(); - expect(Buffer.isBuffer(tracerFile.content)).toBe(true); - expect(tracerFile.content.length).toBeGreaterThan(1000); - - const configFile = written.find((f: any) => f.path.endsWith("arthur_config.json")); - expect(configFile).toBeDefined(); - const cfg = JSON.parse(Buffer.from(configFile.content).toString()); - expect(cfg).toEqual({ - api_key: "test-key", - task_id: "00000000-0000-4000-8000-000000000000", - endpoint: "https://example.ngrok.app/api/v1/traces", - }); + expect(fetchCall).toBeDefined(); + expect(fetchCall![1][1]).toContain("main"); }); - }); diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index a838844..a2a3f24 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,54 +1,21 @@ import type { Sandbox as SandboxType } from "@vercel/sandbox"; import { getSandboxCredentials } from "./credentials.js"; -import { ARTHUR_TRACER_PY_BASE64 } from "./arthur-tracer.js"; - -/** - * Skills installed globally in the sandbox (~/.claude/skills/). - * Global install keeps the client repo completely untouched — no git concerns. - */ -const GLOBAL_SKILLS = [ - { repo: "https://github.com/obra/superpowers", skill: "using-superpowers" }, - { repo: "https://github.com/obra/superpowers", skill: "requesting-code-review" }, - { repo: "https://github.com/anthropics/skills", skill: "frontend-design" }, -] as const; - -export interface ArthurConfig { - apiKey: string; - taskId: string; - endpoint: string; -} +import type { AgentAdapter, ConfigureOpts } from "./agents/types.js"; export interface SandboxConfig { kind: "github" | "gitlab"; token: string; - /** GitHub: "owner/repo", GitLab: project path e.g. "group/repo" */ repoPath: string; - /** VCS host base URL, e.g. https://github.com or https://gitlab.example.com */ host: string; - anthropicApiKey?: string; - claudeCodeOauthToken?: string; - claudeModel: string; + jobTimeoutMs: number; commitAuthor: string; commitEmail: string; - jobTimeoutMs: number; - /** Arthur AI Engine tracing config. If set, the tracer is installed into every provisioned sandbox. */ - arthur?: ArthurConfig; } -/** Build clone/push URLs for the configured VCS. Supports github.com and any GitLab host (incl. self-hosted). */ -export function buildVcsUrls(config: { - kind: "github" | "gitlab"; - token: string; - repoPath: string; - host: string; -}) { - // Strip trailing slash for consistent URL joining. +/** Build clone/push URLs for the configured VCS. Unchanged from previous behaviour. */ +export function buildVcsUrls(config: { kind: "github" | "gitlab"; token: string; repoPath: string; host: string }) { const host = config.host.replace(/\/+$/, ""); - // Preserve the scheme from the configured host so cloneUrl and authUrl agree - // (e.g. http:// for a self-hosted GitLab dev instance must not silently - // become https:// in authUrl). const scheme = host.match(/^https?:\/\//)?.[0] ?? "https://"; - // Extract `host.tld` (no scheme) so we can interpolate credentials into the URL. const hostNoScheme = host.replace(/^https?:\/\//, ""); const authUser = config.kind === "gitlab" ? "oauth2" : "x-access-token"; return { @@ -60,117 +27,16 @@ export function buildVcsUrls(config: { type SandboxInstance = Awaited>; -/** Minimal interface for sandbox objects that support runCommand (works with both Sandbox.create and Sandbox.get). */ -interface RunnableSandbox { - runCommand: SandboxInstance["runCommand"]; -} - -/** - * Merge-aware writer for ~/.claude/settings.json inside a sandbox. - * - * Accepts a partial "directive" — only the keys provided are mutated; existing - * hook entries (including those owned by other tools, e.g. Arthur's tracer) - * are preserved. The merge itself runs inside the sandbox via `node -e` - * because Node 24 is the sandbox runtime and we can't assume Python is - * available for stop-hook toggling. - */ -async function writeClaudeSettings( - sandbox: RunnableSandbox, - opts: { - commitGuard?: "enable" | "disable"; - arthur?: "install"; - }, -): Promise { - const script = ` - import fs from 'node:fs'; - import path from 'node:path'; - const opts = ${JSON.stringify(opts)}; - const home = process.env.HOME; - const settingsPath = path.join(home, '.claude', 'settings.json'); - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - let s = {}; - try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} - s.hooks = s.hooks || {}; - - const upsertHook = (event, matcher, command) => { - const existing = s.hooks[event] || []; - const has = existing.some(e => (e && Array.isArray(e.hooks) ? e.hooks : []).some(h => h && h.command === command)); - if (!has) existing.push({ matcher, hooks: [{ type: 'command', command }] }); - s.hooks[event] = existing; - }; - const removeHook = (event, commandPredicate) => { - const existing = s.hooks[event] || []; - s.hooks[event] = existing - .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !commandPredicate(h.command || '')) })) - .filter(e => (e.hooks || []).length > 0); - }; - - if (opts.commitGuard === 'enable') { - upsertHook('Stop', '', 'bash ~/.claude/commit-guard.sh'); - } else if (opts.commitGuard === 'disable') { - removeHook('Stop', c => c.includes('commit-guard.sh')); - } - - if (opts.arthur === 'install') { - const events = [ - ['UserPromptSubmit', 'user_prompt_submit'], - ['PreToolUse', 'pre_tool'], - ['PostToolUse', 'post_tool'], - ['PostToolUseFailure', 'post_tool_failure'], - ['Stop', 'stop'], - ]; - for (const [event, arg] of events) { - upsertHook(event, '', 'python3 "$HOME/.claude/hooks/claude_code_tracer.py" ' + arg); - } - } - - fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); - `; - await sandbox.runCommand("node", ["--input-type=module", "-e", script]); -} - -/** - * Configures or disables the commit-guard stop hook in a sandbox. - * Standalone function so both SandboxManager and workflow steps can call it - * without type mismatches between Sandbox.create() and Sandbox.get(). - */ -export async function configureStopHookInSandbox(sandbox: RunnableSandbox, enabled: boolean): Promise { - // Ensure the commit-guard script exists before toggling the hook (idempotent). - await sandbox.runCommand("bash", [ - "-c", - [ - `mkdir -p ~/.claude`, - `cat > ~/.claude/commit-guard.sh << 'SCRIPT'`, - `#!/bin/bash`, - `input=$(cat)`, - `if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`, - `changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/')`, - `if [ -n "$changes" ]; then`, - ` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`, - ` exit 2`, - `fi`, - `SCRIPT`, - `chmod +x ~/.claude/commit-guard.sh`, - ].join("\n"), - ]); - - await writeClaudeSettings(sandbox, { commitGuard: enabled ? "enable" : "disable" }); -} - export class SandboxManager { constructor(private config: SandboxConfig) {} async provision( branch: string, - /** If set, fetches and merges this branch (e.g. "main") so the agent can resolve conflicts. */ + agent: AgentAdapter, + configureOpts: ConfigureOpts, mergeBase?: string, ): Promise { const { Sandbox } = await import("@vercel/sandbox"); - - if (!this.config.claudeCodeOauthToken && !this.config.anthropicApiKey) { - throw new Error("Either anthropicApiKey or claudeCodeOauthToken must be provided"); - } - const urls = buildVcsUrls(this.config); const sandbox = await Sandbox.create({ @@ -184,204 +50,41 @@ export class SandboxManager { }, runtime: "node24", timeout: this.config.jobTimeoutMs, - env: { - ...(this.config.claudeCodeOauthToken - ? { CLAUDE_CODE_OAUTH_TOKEN: this.config.claudeCodeOauthToken } - : { ANTHROPIC_API_KEY: this.config.anthropicApiKey! }), - CLAUDE_MODEL: this.config.claudeModel, - }, }); - // Strip auth from origin — the clone URL contains the token, replace it - // with the unauthenticated URL so the agent never has push access. - await sandbox.runCommand("git", [ - "remote", "set-url", "origin", urls.cloneUrl, - ]); - - // The sandbox clones a specific revision, which leaves git in detached HEAD. - // Create a local branch so pushFromSandbox can push without HEAD resolution issues. + // Strip auth from origin + await sandbox.runCommand("git", ["remote", "set-url", "origin", urls.cloneUrl]); + // Re-create the local branch (clone is detached HEAD on a revision) await sandbox.runCommand("git", ["checkout", "-B", branch]); - - // Configure git identity + // Identity await sandbox.runCommand("bash", [ "-c", `git config user.name "${this.config.commitAuthor}" && git config user.email "${this.config.commitEmail}"`, ]); - // Merge base branch so the agent can see and resolve conflicts. - // The shallow clone has no remote, so we fetch directly via authenticated URL. if (mergeBase) { const repoUrl = urls.authUrl; - const fetchResult = await sandbox.runCommand("bash", [ - "-c", - `git fetch "${repoUrl}" ${mergeBase} 2>&1`, - ]); - // Create a named local branch so the agent can reference it (e.g. `git show main:path`) - await sandbox.runCommand("bash", [ - "-c", - `git branch ${mergeBase} FETCH_HEAD 2>/dev/null || true`, - ]); - const mergeResult = await sandbox.runCommand("bash", [ - "-c", - `git merge FETCH_HEAD --no-edit 2>&1`, - ]); - if (mergeResult.exitCode !== 0) { - const mergeOutput = (await mergeResult.stdout()).trim(); + await sandbox.runCommand("bash", ["-c", `git fetch "${repoUrl}" ${mergeBase} 2>&1`]); + await sandbox.runCommand("bash", ["-c", `git branch ${mergeBase} FETCH_HEAD 2>/dev/null || true`]); + const merge = await sandbox.runCommand("bash", ["-c", `git merge FETCH_HEAD --no-edit 2>&1`]); + if (merge.exitCode !== 0) { + const out = (await merge.stdout()).trim(); const { logger } = await import("../lib/logger.js"); - logger.warn( - { mergeBase, exitCode: mergeResult.exitCode, output: mergeOutput.slice(0, 500) }, - "merge_conflicts_during_provision", - ); + logger.warn({ mergeBase, exitCode: merge.exitCode, output: out.slice(0, 500) }, "merge_conflicts_during_provision"); } } - // Record the pre-agent HEAD so pushFromSandbox can detect whether the agent made commits. - // Must happen after clone + optional merge, before the agent touches anything. - await sandbox.runCommand("bash", [ - "-c", - "git rev-parse HEAD > /tmp/.pre-agent-sha", - ]); + // Pre-agent SHA so push step can detect commits + await sandbox.runCommand("bash", ["-c", "git rev-parse HEAD > /tmp/.pre-agent-sha"]); - // Install Claude Code - await sandbox.runCommand("npm", ["install", "-g", "@anthropic-ai/claude-code"]); - - // Write auth env vars to a file that phase scripts can source. - // Sandbox.create({ env }) does NOT propagate vars to runCommand sessions, - // so we persist them to disk and source before every `claude` invocation. - // NOTE: Only auth credentials go here. CLAUDE_MODEL is passed via the - // explicit --model flag in phase scripts and poll-agent to keep one source of truth. - const envLines: string[] = []; - if (this.config.claudeCodeOauthToken) { - envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${this.shellQuote(this.config.claudeCodeOauthToken)}`); - } else if (this.config.anthropicApiKey) { - envLines.push(`export ANTHROPIC_API_KEY=${this.shellQuote(this.config.anthropicApiKey)}`); - } - - await sandbox.writeFiles([ - { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, - ]); - await sandbox.runCommand("chmod", ["600", "/tmp/agent-env.sh"]); - - // Skip interactive onboarding (required for headless auth — both OAuth and API key) - await sandbox.runCommand("bash", [ - "-c", - `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`, - ]); - - // Install skills globally (outside the client repo) - await this.installGlobalSkills(sandbox); - - await this.installArthurTracer(sandbox); + // --- Agent-specific work delegated to the adapter --- + await agent.install(sandbox); + await agent.configure(sandbox, configureOpts); return sandbox; } - /** - * Install the Arthur AI Engine Claude Code tracer into the sandbox. - * - * No-op if the three credentials are not all configured on the SandboxManager. - * The tracer hooks into every Claude Code turn and exports OpenInference spans - * via OTLP/HTTP to the configured endpoint. - * - * If pip install fails (e.g. missing python3, offline), we log and return - * without registering hooks — failing hooks would block Claude Code turns. - */ - private async installArthurTracer(sandbox: SandboxInstance): Promise { - const { logger } = await import("../lib/logger.js"); - const arthur = this.config.arthur; - if (!arthur) { - logger.info({}, "arthur_install_skipped_no_config"); - return; - } - - logger.info({ endpoint: arthur.endpoint, taskId: arthur.taskId }, "arthur_install_started"); - - // The Vercel node24 sandbox ships python3 but no pip. Bootstrap it via - // ensurepip (idempotent), then install the two OpenTelemetry packages. - const pip = await sandbox.runCommand("bash", [ - "-c", - "python3 -m ensurepip --user && python3 -m pip install --user --quiet 'opentelemetry-sdk>=1.20.0' 'opentelemetry-exporter-otlp-proto-http>=1.20.0'", - ]); - if (pip.exitCode !== 0) { - const err = (await pip.stderr()).trim(); - logger.warn({ err: err.slice(0, 500) }, "arthur_pip_install_failed"); - return; - } - - // Tracer + config must all land successfully before we register hooks. - // A partial install (e.g. mv fails after pip succeeds) would leave hooks - // pointing at a missing tracer file and break every Claude Code turn. - try { - const tracerBytes = Buffer.from(ARTHUR_TRACER_PY_BASE64, "base64"); - await sandbox.writeFiles([ - { path: "/tmp/arthur-tracer.py", content: tracerBytes }, - ]); - const mvTracer = await sandbox.runCommand("bash", [ - "-c", - "mkdir -p $HOME/.claude/hooks && mv /tmp/arthur-tracer.py $HOME/.claude/hooks/claude_code_tracer.py && chmod +x $HOME/.claude/hooks/claude_code_tracer.py", - ]); - if (mvTracer.exitCode !== 0) { - const err = (await mvTracer.stderr()).trim(); - logger.warn({ err: err.slice(0, 500) }, "arthur_tracer_install_failed"); - return; - } - - const configJson = JSON.stringify( - { api_key: arthur.apiKey, task_id: arthur.taskId, endpoint: arthur.endpoint }, - null, - 2, - ); - await sandbox.writeFiles([ - { path: "/tmp/arthur_config.json", content: Buffer.from(configJson) }, - ]); - const mvConfig = await sandbox.runCommand("bash", [ - "-c", - "mkdir -p $HOME/.claude && mv /tmp/arthur_config.json $HOME/.claude/arthur_config.json && chmod 600 $HOME/.claude/arthur_config.json", - ]); - if (mvConfig.exitCode !== 0) { - const err = (await mvConfig.stderr()).trim(); - logger.warn({ err: err.slice(0, 500) }, "arthur_config_install_failed"); - return; - } - } catch (err) { - logger.warn( - { err: err instanceof Error ? err.message : String(err) }, - "arthur_tracer_install_failed", - ); - return; - } - - await writeClaudeSettings(sandbox, { arthur: "install" }); - logger.info({}, "arthur_install_complete"); - } - - /** - * Install Claude Code skills globally in the sandbox (~/.claude/skills/). - * Global install keeps the client repo completely untouched. - */ - private async installGlobalSkills(sandbox: SandboxInstance): Promise { - for (const { repo, skill } of GLOBAL_SKILLS) { - await sandbox.runCommand("npx", [ - "-y", "skills", "add", repo, "--skill", skill, "--yes", "-g", - ]); - } - } - - /** Safely quote a value for use in a shell variable assignment. */ - private shellQuote(val: string): string { - // Single-quote the value, escaping any embedded single quotes. - return `'${val.replace(/'/g, "'\\''")}'`; - } - - async configureStopHook(sandbox: SandboxInstance, enabled: boolean): Promise { - await configureStopHookInSandbox(sandbox, enabled); - } - async teardown(sandbox: SandboxInstance): Promise { - try { - await sandbox.stop(); - } catch { - // Teardown failures are non-critical - } + try { await sandbox.stop(); } catch { /* non-critical */ } } } diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index 4120f27..e85846c 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -67,7 +67,7 @@ vi.mock("../../env.js", () => ({ getVcsConfig: () => currentVcsConfig, })); -import { pushFromSandbox, fixAndRetryPush, teardownSandbox, checkPhaseDone, collectPhaseOutput } from "./poll-agent.js"; +import { pushFromSandbox, fixAndRetryPush, teardownSandbox, checkPhaseDone, collectPhaseOutput, collectPhase } from "./poll-agent.js"; describe("pushFromSandbox", () => { beforeEach(() => { @@ -221,7 +221,9 @@ describe("fixAndRetryPush", () => { }); mockWriteFiles.mockResolvedValue(undefined); - const result = await fixAndRetryPush("sbx-test-123", "blazebot/task-1", "lint failed"); + const result = await fixAndRetryPush( + "sbx-test-123", "blazebot/task-1", "lint failed", "claude", "claude-sonnet-4-20250514", + ); expect(result.pushed).toBe(true); // Verify prompt was written to file (not echoed into shell) @@ -232,6 +234,27 @@ describe("fixAndRetryPush", () => { expect(mockRunCommand).toHaveBeenCalledWith("git", ["push", "--force", "origin", "HEAD:refs/heads/blazebot/task-1"]); }); + it("invokes codex CLI when agentKind=codex", async () => { + mockRunCommand.mockImplementation(() => ({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + })); + mockWriteFiles.mockResolvedValue(undefined); + + await fixAndRetryPush( + "sbx-test-123", "blazebot/task-1", "lint failed", "codex", "gpt-5-codex", + ); + + const fixCall = mockRunCommand.mock.calls.find( + ([cmd, args]) => cmd === "bash" && typeof args?.[1] === "string" && args[1].includes("/tmp/fix-prompt.txt"), + ); + expect(fixCall).toBeDefined(); + expect(fixCall![1][1]).toContain("codex exec"); + expect(fixCall![1][1]).toContain("gpt-5-codex"); + expect(fixCall![1][1]).not.toContain("claude --print"); + }); + it("returns error when retry push also fails", async () => { const callIndex = { value: 0 }; mockRunCommand.mockImplementation(() => { @@ -250,7 +273,9 @@ describe("fixAndRetryPush", () => { }); mockWriteFiles.mockResolvedValue(undefined); - const result = await fixAndRetryPush("sbx-test-123", "blazebot/task-1", "lint failed"); + const result = await fixAndRetryPush( + "sbx-test-123", "blazebot/task-1", "lint failed", "claude", "claude-sonnet-4-20250514", + ); expect(result.pushed).toBe(false); expect(result.error).toBe("still failing"); @@ -357,3 +382,62 @@ describe("collectPhaseOutput", () => { expect(result).toBe("error details from phase"); }); }); + +describe("collectPhase", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns raw + structured when structuredOutput is set", async () => { + mockRunCommand.mockImplementation((_cmd: string, args: string[]) => { + const file = args[0]; + const text = + file.includes("stdout") ? "ndjson body" : + file.includes("stderr") ? "" : + file.includes("result") ? '{"result":"implemented"}' : + ""; + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(text) }; + }); + + const result = await collectPhase("sbx-test-123", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: "/tmp/impl-result.json", + }); + + expect(result.raw).toBe("ndjson body"); + expect(result.structured).toBe('{"result":"implemented"}'); + }); + + it("returns structured=null when paths.structuredOutput is null", async () => { + mockRunCommand.mockImplementation((_cmd: string, args: string[]) => { + const file = args[0]; + const text = file.includes("stdout") ? "raw text" : ""; + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(text) }; + }); + + const r = await collectPhase("sbx-test-123", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: null, + }); + expect(r.structured).toBeNull(); + expect(r.raw).toBe("raw text"); + }); + + it("falls back to stderr when stdout is empty", async () => { + mockRunCommand.mockImplementation((_cmd: string, args: string[]) => { + const file = args[0]; + const text = + file.includes("stdout") ? "" : + file.includes("stderr") ? "stderr text" : + ""; + return { exitCode: 0, stdout: vi.fn().mockResolvedValue(text) }; + }); + + const r = await collectPhase("sbx-test-123", { + stdout: "/tmp/impl-stdout.txt", + stderr: "/tmp/impl-stderr.txt", + structuredOutput: null, + }); + expect(r.raw).toBe("stderr text"); + }); +}); diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index 848f99f..caebdf7 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -65,10 +65,12 @@ export async function fixAndRetryPush( sandboxId: string, branch: string, pushError: string, + agentKind: "claude" | "codex", + model: string, ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); - const { env, getVcsConfig } = await import("../../env.js"); + const { getVcsConfig } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); const urls = buildVcsUrls(getVcsConfig()); @@ -83,9 +85,19 @@ export async function fixAndRetryPush( { path: "/tmp/fix-prompt.txt", content: Buffer.from(fixPrompt) }, ]); + // Same CLI flags as the main phase scripts, minus structured output / schema. + // Codex needs `--skip-git-repo-check` (sandbox sees the repo as dirty after + // the agent's changes) and `--dangerously-bypass-approvals-and-sandbox` to + // match the main run; otherwise its inner sandbox would reject edits inside + // the Vercel microVM. + const cli = + agentKind === "codex" + ? `codex exec --model "${model}" --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --json -` + : `claude --print --model '${model}' --dangerously-skip-permissions`; + await sandbox.runCommand("bash", [ "-c", - `[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh; cat /tmp/fix-prompt.txt | claude --print --model '${env.CLAUDE_MODEL}' --dangerously-skip-permissions > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, + `[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh; cat /tmp/fix-prompt.txt | ${cli} > /tmp/fix-stdout.txt 2>/tmp/fix-stderr.txt || true`, ]); // Log fix agent output for observability @@ -155,6 +167,33 @@ export async function collectPhaseOutput( return stdout || stderr; } +/** + * Collect raw + (optional) structured phase output. Replaces collectPhaseOutput + * in adapter-aware code paths. + */ +export async function collectPhase( + sandboxId: string, + paths: { stdout: string; stderr: string; structuredOutput: string | null }, +): Promise<{ raw: string; structured: string | null }> { + "use step"; + const { Sandbox } = await import("@vercel/sandbox"); + const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); + + const stdoutResult = await sandbox.runCommand("cat", [paths.stdout]); + const stdoutText = (await stdoutResult.stdout()).trim(); + const stderrResult = await sandbox.runCommand("cat", [paths.stderr]); + const stderrText = (await stderrResult.stdout()).trim(); + const raw = stdoutText || stderrText; + + let structured: string | null = null; + if (paths.structuredOutput) { + const r = await sandbox.runCommand("cat", [paths.structuredOutput]); + const text = (await r.stdout()).trim(); + structured = text || null; + } + return { raw, structured }; +} + /** * Reconnects to a sandbox and stops it. */ diff --git a/src/sandbox/usage.test.ts b/src/sandbox/usage.test.ts index dfde69e..4fcdbe7 100644 --- a/src/sandbox/usage.test.ts +++ b/src/sandbox/usage.test.ts @@ -1,152 +1,39 @@ import { describe, it, expect } from "vitest"; -import { extractUsage, unwrapResearchText, formatUsageReport, type PhaseUsage } from "./usage.js"; +import { formatUsageReport, type PhaseUsage } from "./usage.js"; -describe("extractUsage", () => { - it("extracts usage from a single JSON result envelope", () => { - const raw = JSON.stringify({ - type: "result", - subtype: "success", - cost_usd: 0.053, - duration_ms: 120000, - duration_api_ms: 45000, - num_turns: 15, - result: "STATUS: completed\n\nPlan here", - }); - const usage = extractUsage(raw); - expect(usage).toEqual({ - cost_usd: 0.053, - duration_ms: 120000, - duration_api_ms: 45000, - num_turns: 15, - }); - }); - - it("extracts usage from stream-json with multiple lines", () => { - const lines = [ - JSON.stringify({ type: "assistant", content: "Working on it..." }), - JSON.stringify({ - type: "result", - subtype: "success", - cost_usd: 0.08, - duration_ms: 200000, - duration_api_ms: 60000, - num_turns: 10, - structured_output: { result: "implemented", summary: "Done" }, - }), - ]; - const usage = extractUsage(lines.join("\n")); - expect(usage).toEqual({ - cost_usd: 0.08, - duration_ms: 200000, - duration_api_ms: 60000, - num_turns: 10, - }); - }); - - it("uses total_cost_usd when cost_usd is missing", () => { - const raw = JSON.stringify({ - type: "result", - subtype: "success", - total_cost_usd: 0.12, - duration_ms: 50000, - duration_api_ms: 30000, - num_turns: 5, - result: "done", - }); - const usage = extractUsage(raw); - expect(usage?.cost_usd).toBe(0.12); - }); - - it("returns null for empty input", () => { - expect(extractUsage("")).toBeNull(); - expect(extractUsage(" ")).toBeNull(); - }); - - it("returns null for plain text without envelope", () => { - expect(extractUsage("STATUS: completed\n\nSome plan")).toBeNull(); - }); - - it("returns null for JSON without cost fields", () => { - const raw = JSON.stringify({ type: "result", subtype: "success", result: "ok" }); - expect(extractUsage(raw)).toBeNull(); - }); - - it("defaults missing duration/turns to 0", () => { - const raw = JSON.stringify({ - type: "result", - subtype: "success", - cost_usd: 0.01, - result: "ok", - }); - const usage = extractUsage(raw); - expect(usage).toEqual({ - cost_usd: 0.01, - duration_ms: 0, - duration_api_ms: 0, - num_turns: 0, - }); - }); -}); - -describe("unwrapResearchText", () => { - it("extracts result text from JSON envelope", () => { - const raw = JSON.stringify({ - type: "result", - subtype: "success", - cost_usd: 0.05, - result: "STATUS: completed\n\n# Plan\n1. Do stuff", - }); - const text = unwrapResearchText(raw); - expect(text).toBe("STATUS: completed\n\n# Plan\n1. Do stuff"); - }); - - it("returns plain text as-is when no envelope", () => { - const raw = "STATUS: completed\n\nPlan here"; - expect(unwrapResearchText(raw)).toBe(raw); - }); - - it("returns empty string for empty input", () => { - expect(unwrapResearchText("")).toBe(""); - }); - - it("returns raw when envelope has non-string result", () => { - const raw = JSON.stringify({ - type: "result", - subtype: "success", - result: { nested: true }, - }); - expect(unwrapResearchText(raw)).toBe(raw); - }); +const u = (over: Partial = {}): PhaseUsage => ({ + cost_usd: null, tokens: null, duration_ms: 60_000, duration_api_ms: 30_000, num_turns: 1, ...over, }); describe("formatUsageReport", () => { - it("formats multiple phases with total", () => { - const phases: Record = { - Research: { cost_usd: 0.03, duration_ms: 120000, duration_api_ms: 45000, num_turns: 10 }, - Impl: { cost_usd: 0.10, duration_ms: 900000, duration_api_ms: 300000, num_turns: 25 }, - Review: { cost_usd: 0.02, duration_ms: 180000, duration_api_ms: 60000, num_turns: 5 }, - }; - const report = formatUsageReport(phases); - expect(report).toContain("$0.15 total"); - expect(report).toContain("Research: $0.03 (2m)"); - expect(report).toContain("Impl: $0.10 (15m)"); - expect(report).toContain("Review: $0.02 (3m)"); - }); - - it("shows n/a for phases with null usage", () => { - const phases: Record = { - Research: null, - Impl: { cost_usd: 0.05, duration_ms: 60000, duration_api_ms: 30000, num_turns: 3 }, - }; - const report = formatUsageReport(phases); - expect(report).toContain("Research: n/a"); - expect(report).toContain("Impl: $0.05 (1m)"); - expect(report).toContain("$0.05 total"); - }); - - it("handles all null phases", () => { - const report = formatUsageReport({ Research: null, Impl: null }); - expect(report).toContain("$0.00 total"); - expect(report).toContain("Research: n/a"); + it("uses cost_usd when present", () => { + const out = formatUsageReport({ Impl: u({ cost_usd: 1.23 }) }); + expect(out).toContain("$1.23"); + expect(out).toContain("$1.23 total"); + }); + + it("computes cost from tokens + priceLookup when cost_usd is null", () => { + const out = formatUsageReport( + { Impl: u({ tokens: { input: 1000, cached_input: 0, output: 500 } }) }, + () => ({ input: 0.000003, cached_input: 0, output: 0.000015 }), + "gpt-5-codex", + ); + expect(out).toMatch(/\$0\.0[01]/); + expect(out).not.toContain("cost unknown"); + }); + + it("falls back to tokens-only when no price and tokens are present", () => { + const out = formatUsageReport( + { Impl: u({ tokens: { input: 100, cached_input: 0, output: 50 } }) }, + () => null, + "unknown-model", + ); + expect(out).toContain("100/50 tok (cost unknown)"); + expect(out).toContain("+ total"); + }); + + it("shows n/a for null phases", () => { + const out = formatUsageReport({ Impl: null }); + expect(out).toContain("Impl: n/a"); }); }); diff --git a/src/sandbox/usage.ts b/src/sandbox/usage.ts index c83d835..c93798c 100644 --- a/src/sandbox/usage.ts +++ b/src/sandbox/usage.ts @@ -1,115 +1,58 @@ -/** - * Extracts Claude Code usage/cost data from the JSON result envelope - * that `claude --print --output-format json` outputs. - */ - -export interface PhaseUsage { - cost_usd: number; - duration_ms: number; - duration_api_ms: number; - num_turns: number; -} - -/** - * Scans raw agent output for a Claude Code result envelope and extracts cost fields. - * Works with both single-object JSON and stream-json (newline-delimited) formats. - * Returns null if no usage data is found (e.g. agent crashed before producing output). - */ -export function extractUsage(raw: string): PhaseUsage | null { - if (!raw.trim()) return null; +import type { PhaseUsage } from "./agents/types.js"; +import type { TokenPrice } from "./agents/pricing.js"; - // Try single JSON object first (--output-format json) - const envelope = findResultEnvelope(raw); - if (!envelope) return null; +export type { PhaseUsage } from "./agents/types.js"; +export type { TokenPrice }; - const cost = - typeof envelope.cost_usd === "number" - ? envelope.cost_usd - : typeof envelope.total_cost_usd === "number" - ? envelope.total_cost_usd - : null; - if (cost === null) return null; - - return { - cost_usd: cost, - duration_ms: - typeof envelope.duration_ms === "number" ? envelope.duration_ms : 0, - duration_api_ms: - typeof envelope.duration_api_ms === "number" - ? envelope.duration_api_ms - : 0, - num_turns: typeof envelope.num_turns === "number" ? envelope.num_turns : 0, - }; -} +export type PriceLookup = (model: string) => TokenPrice | null; /** - * Unwraps the text content from a Claude Code JSON result envelope. - * Used for the research phase which outputs free-form text (no --json-schema). + * Slack-friendly usage line. Computes Codex costs from tokens when a price + * is available; falls back to "cost unknown" for Codex without pricing. * - * If the raw output is already plain text (no envelope), returns it as-is. - */ -export function unwrapResearchText(raw: string): string { - if (!raw.trim()) return raw; - - const envelope = findResultEnvelope(raw); - if (!envelope) return raw; - - // The text content lives in the `result` field of the envelope - if (typeof envelope.result === "string") { - return envelope.result; - } - - // Fallback: return raw (shouldn't happen with --output-format json) - return raw; -} - -/** - * Formats accumulated phase usage data into a compact Slack-friendly string. + * For each phase: + * - cost_usd != null → use it directly (Claude path) + * - tokens != null + priceLookup yields a price → compute cost + * - else → tokens-only, marked "cost unknown" */ export function formatUsageReport( phases: Record, + priceLookup?: PriceLookup, + model?: string, ): string { const parts: string[] = []; let totalCost = 0; + let anyUnknown = false; for (const [name, usage] of Object.entries(phases)) { - if (!usage) { - parts.push(`${name}: n/a`); - continue; - } - totalCost += usage.cost_usd; + if (!usage) { parts.push(`${name}: n/a`); continue; } const mins = Math.round(usage.duration_ms / 60_000); - parts.push(`${name}: $${usage.cost_usd.toFixed(2)} (${mins}m)`); - } - - return `Usage: $${totalCost.toFixed(2)} total | ${parts.join(" | ")}`; -} - -// --- Internal --- - -function findResultEnvelope(raw: string): Record | null { - // Try parsing as a single JSON object - try { - const obj = JSON.parse(raw); - if (obj && typeof obj === "object" && obj.type === "result") { - return obj as Record; - } - } catch { - // Not a single JSON object — try line-by-line - } - - // Scan lines in reverse for a result envelope (stream-json format) - const lines = raw.split("\n").filter(Boolean); - for (let i = lines.length - 1; i >= 0; i--) { - try { - const obj = JSON.parse(lines[i]); - if (obj && typeof obj === "object" && obj.type === "result") { - return obj as Record; + let costLabel: string; + if (usage.cost_usd != null) { + totalCost += usage.cost_usd; + costLabel = `$${usage.cost_usd.toFixed(2)}`; + } else if (usage.tokens && priceLookup && model) { + const price = priceLookup(model); + if (price) { + const cost = usage.tokens.input * price.input + + usage.tokens.cached_input * price.cached_input + + usage.tokens.output * price.output; + totalCost += cost; + costLabel = `$${cost.toFixed(2)}`; + } else { + anyUnknown = true; + costLabel = `${usage.tokens.input}/${usage.tokens.output} tok (cost unknown)`; } - } catch { - // Not valid JSON, try next line + } else if (usage.tokens) { + anyUnknown = true; + costLabel = `${usage.tokens.input}/${usage.tokens.output} tok (cost unknown)`; + } else { + anyUnknown = true; + costLabel = "cost unknown"; } + parts.push(`${name}: ${costLabel} (${mins}m)`); } - return null; + const total = anyUnknown ? `$${totalCost.toFixed(2)}+ total` : `$${totalCost.toFixed(2)} total`; + return `Usage: ${total} | ${parts.join(" | ")}`; } diff --git a/src/sandbox/wrapper-script.test.ts b/src/sandbox/wrapper-script.test.ts deleted file mode 100644 index 487cb21..0000000 --- a/src/sandbox/wrapper-script.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { buildPhaseScript } from "./wrapper-script.js"; - -describe("buildPhaseScript", () => { - it("generates research phase script without json-schema", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "research", - inputFile: "/tmp/research-requirements.md", - outputFile: "/tmp/research-stdout.txt", - stderrFile: "/tmp/research-stderr.txt", - sentinelFile: "/tmp/research-done", - }); - - expect(script).toContain("#!/bin/bash"); - expect(script).toContain("claude"); - expect(script).toContain("claude-opus-4-6"); - expect(script).toContain("/tmp/research-requirements.md"); - expect(script).toContain("/tmp/research-stdout.txt"); - expect(script).toContain("/tmp/research-stderr.txt"); - expect(script).toContain("/tmp/research-done"); - expect(script).not.toContain("--json-schema"); - expect(script).toContain("--output-format json"); - }); - - it("generates impl phase script with json-schema", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "impl", - inputFile: "/tmp/impl-requirements.md", - outputFile: "/tmp/impl-stdout.txt", - stderrFile: "/tmp/impl-stderr.txt", - sentinelFile: "/tmp/impl-done", - jsonSchema: '{"type":"object"}', - }); - - expect(script).toContain("--json-schema"); - expect(script).toContain("--output-format json"); - expect(script).toContain("/tmp/impl-requirements.md"); - expect(script).toContain("/tmp/impl-done"); - }); - - it("generates review phase script with json-schema", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "review", - inputFile: "/tmp/review-requirements.md", - outputFile: "/tmp/review-stdout.txt", - stderrFile: "/tmp/review-stderr.txt", - sentinelFile: "/tmp/review-done", - jsonSchema: '{"type":"object"}', - }); - - expect(script).toContain("--json-schema"); - expect(script).toContain("/tmp/review-requirements.md"); - expect(script).toContain("/tmp/review-done"); - }); - - it("includes cleanup and sentinel touch", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "research", - inputFile: "/tmp/research-requirements.md", - outputFile: "/tmp/research-stdout.txt", - stderrFile: "/tmp/research-stderr.txt", - sentinelFile: "/tmp/research-done", - }); - - expect(script).toContain("rm -rf .claude/"); - expect(script).toContain("touch /tmp/research-done"); - }); - - it("removes stale sentinel, stdout, and stderr before running", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "impl", - inputFile: "/tmp/impl-requirements.md", - outputFile: "/tmp/impl-stdout.txt", - stderrFile: "/tmp/impl-stderr.txt", - sentinelFile: "/tmp/impl-done", - jsonSchema: '{"type":"object"}', - }); - - // Cleanup line must appear before the claude invocation - const cleanupIdx = script.indexOf("rm -f /tmp/impl-done /tmp/impl-stdout.txt /tmp/impl-stderr.txt"); - const claudeIdx = script.indexOf("claude"); - expect(cleanupIdx).toBeGreaterThan(-1); - expect(cleanupIdx).toBeLessThan(claudeIdx); - }); - - it("escapes single quotes in json schema", () => { - const script = buildPhaseScript({ - model: "claude-opus-4-6", - phase: "impl", - inputFile: "/tmp/impl-requirements.md", - outputFile: "/tmp/impl-stdout.txt", - stderrFile: "/tmp/impl-stderr.txt", - sentinelFile: "/tmp/impl-done", - jsonSchema: `{"type":"object","desc":"it's"}`, - }); - - expect(script).not.toContain("it's"); - expect(script).toContain("it'\\''s"); - }); -}); diff --git a/src/sandbox/wrapper-script.ts b/src/sandbox/wrapper-script.ts deleted file mode 100644 index 2486f42..0000000 --- a/src/sandbox/wrapper-script.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface PhaseScriptOptions { - model: string; - phase: "research" | "impl" | "review"; - inputFile: string; - outputFile: string; - stderrFile: string; - sentinelFile: string; - jsonSchema?: string; -} - -/** - * Generates a bash script for a single agent phase. - * Designed to run detached inside a Vercel Sandbox. - */ -export function buildPhaseScript(opts: PhaseScriptOptions): string { - const { model, inputFile, outputFile, stderrFile, sentinelFile, jsonSchema } = opts; - - let claudeFlags = `--print --model '${model}' --dangerously-skip-permissions --output-format json`; - - if (jsonSchema) { - const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); - claudeFlags += ` --json-schema '${escapedSchema}'`; - } - - return `#!/bin/bash - -# --- Cleanup stale files from prior runs --- -rm -f ${sentinelFile} ${outputFile} ${stderrFile} - -# --- Source auth env vars (Sandbox.create env does not propagate to runCommand) --- -[ -f /tmp/agent-env.sh ] && source /tmp/agent-env.sh - -# --- Phase: ${opts.phase} --- -cat ${inputFile} | claude \\ - ${claudeFlags} \\ - > ${outputFile} 2>${stderrFile}; echo $? > /tmp/${opts.phase}-exit-code || true - -# --- Cleanup --- -cd /vercel/sandbox - -# Remove repo-level .claude/ artifacts that Claude Code auto-creates. -# git checkout restores any that were already committed. -rm -rf .claude/ -git checkout -- .claude/ 2>/dev/null || true - -# --- Signal completion --- -touch ${sentinelFile} -`; -} diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index b8aed74..fda1b65 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -1,10 +1,11 @@ import { sleep } from "workflow"; -import type { AgentOutput } from "../sandbox/agent-runner.js"; -import type { ReviewOutput } from "../sandbox/agent-runner.js"; +import type { + AgentOutput, ReviewOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, +} from "../sandbox/agents/types.js"; +import type { AgentKind } from "../sandbox/agents/index.js"; import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; import type { TicketAttachment } from "../adapters/issue-tracker/types.js"; import type { DownloadedAttachment } from "../sandbox/attachments.js"; -import type { PhaseUsage } from "../sandbox/usage.js"; // --- Step Functions --- @@ -161,11 +162,13 @@ ensureArthurTaskForTicket.maxRetries = 0; async function provisionSandbox( branchName: string, arthurTaskId: string | null, + agentKindOverride: AgentKind | null, mergeBase?: string, -): Promise { +): Promise<{ sandboxId: string; agentKind: AgentKind }> { "use step"; const { env, getVcsConfig } = await import("../../env.js"); const { SandboxManager } = await import("../sandbox/manager.js"); + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); const vcs = getVcsConfig(); // The sandbox builds clone/push URLs by interpolating repoPath into a URL, @@ -190,22 +193,39 @@ async function provisionSandbox( } : undefined; + const agentKind: AgentKind = agentKindOverride ?? env.AGENT_KIND; + if (agentKind === "codex" && !env.CODEX_API_KEY && !env.CODEX_CHATGPT_OAUTH_TOKEN) { + throw new Error( + "agent override agent:codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN in the deployed environment", + ); + } + if (agentKind === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + throw new Error( + "agent override agent:claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in the deployed environment", + ); + } + const agent = createAgentAdapter(agentKind); + const manager = new SandboxManager({ kind: vcs.kind, token: vcs.token, repoPath: vcs.repoPath, host: vcs.host, - anthropicApiKey: env.ANTHROPIC_API_KEY, - claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, - claudeModel: env.CLAUDE_MODEL, + jobTimeoutMs: env.JOB_TIMEOUT_MS, commitAuthor: env.COMMIT_AUTHOR, commitEmail: env.COMMIT_EMAIL, - jobTimeoutMs: env.JOB_TIMEOUT_MS, - arthur, }); - const sandbox = await manager.provision(branchName, mergeBase); - return sandbox.sandboxId; + const sandbox = await manager.provision(branchName, agent, { + anthropicApiKey: env.ANTHROPIC_API_KEY, + claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + codexApiKey: env.CODEX_API_KEY, + codexChatGptOauthToken: env.CODEX_CHATGPT_OAUTH_TOKEN, + model: agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL, + arthur, + }, mergeBase); + + return { sandboxId: sandbox.sandboxId, agentKind }; } provisionSandbox.maxRetries = 0; @@ -237,14 +257,68 @@ async function writeAndStartPhase( } writeAndStartPhase.maxRetries = 0; -async function configureStopHook(sandboxId: string, enabled: boolean): Promise { +async function fetchCodexPriceStep(model: string): Promise<{ input: number; cached_input: number; output: number } | null> { + "use step"; + const { fetchModelPrice } = await import("../sandbox/agents/pricing.js"); + try { + return await fetchModelPrice(model); + } catch (err) { + const { logger } = await import("../lib/logger.js"); + logger.warn({ err: (err as Error).message, model }, "pricing_fetch_failed"); + return null; + } +} +fetchCodexPriceStep.maxRetries = 0; + +async function setCommitGuardStep(sandboxId: string, agentKind: AgentKind, enabled: boolean): Promise { "use step"; const { Sandbox } = await import("@vercel/sandbox"); const { getSandboxCredentials } = await import("../sandbox/credentials.js"); - const { configureStopHookInSandbox } = await import("../sandbox/manager.js"); + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - await configureStopHookInSandbox(sandbox, enabled); + const agent = createAgentAdapter(agentKind); + await agent.setCommitGuard(sandbox, enabled); +} + +// Step wrappers around the AgentAdapter class methods. The adapter classes +// transitively reach the pino logger (via installArthurTracer); the workflow +// bundler can't tolerate that, so all adapter method calls happen inside +// step bundles rather than the workflow body. +async function planPhaseStep( + agentKind: AgentKind, + phase: PhaseKind, + model: string, + jsonSchema?: string, +): Promise<{ paths: PhaseArtifactPaths; script: string }> { + "use step"; + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + const a = createAgentAdapter(agentKind); + const paths = a.artifactPaths(phase); + const script = a.buildPhaseScript({ phase, model, paths, jsonSchema }); + return { paths, script }; +} + +async function parseResearchStep( + agentKind: AgentKind, + raw: string, + structured: string | null, +): Promise<{ research: ResearchResult; usage: PhaseUsage | null }> { + "use step"; + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + const a = createAgentAdapter(agentKind); + return { research: a.parseResearchStatus(raw, structured), usage: a.extractUsage(raw, structured) }; +} + +async function parseAgentOutputStep( + agentKind: AgentKind, + raw: string, + structured: string | null, +): Promise<{ output: AgentOutput; usage: PhaseUsage | null }> { + "use step"; + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + const a = createAgentAdapter(agentKind); + return { output: a.parseAgentOutput(raw, structured), usage: a.extractUsage(raw, structured) }; } async function captureGitDiff(sandboxId: string): Promise { @@ -353,15 +427,12 @@ export async function agentWorkflow(ticketId: string) { "use workflow"; const { env, getVcsConfig } = await import("../../env.js"); - const { buildPhaseScript } = await import("../sandbox/wrapper-script.js"); - const { parseResearchStatus, parseAgentOutput, parseReviewOutput, REVIEW_SCHEMA, AGENT_SCHEMA } = - await import("../sandbox/agent-runner.js"); - const { assembleResearchPlanContext, assembleImplementationContext, assembleReviewContext } = + const { assembleResearchPlanContext, assembleImplementationContext } = await import("../sandbox/context.js"); - const { collectPhaseOutput, pushFromSandbox, fixAndRetryPush, teardownSandbox } = + const { collectPhase, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); - const { extractUsage, unwrapResearchText, formatUsageReport } = - await import("../sandbox/usage.js"); + const { formatUsageReport } = await import("../sandbox/usage.js"); + const { AGENT_SCHEMA } = await import("../sandbox/agents/types.js"); const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return; @@ -370,9 +441,12 @@ export async function agentWorkflow(ticketId: string) { const prompts = await loadPrompts(); const phaseUsages: Record = {}; + // Set after provisionSandbox once agentKind is known. + let activeModel: string | undefined; + let priceLookup: ((m: string) => { input: number; cached_input: number; output: number } | null) | undefined; const usageSuffix = () => Object.keys(phaseUsages).length - ? `\n${formatUsageReport(phaseUsages)}` + ? `\n${formatUsageReport(phaseUsages, priceLookup, activeModel)}` : ""; try { @@ -400,18 +474,30 @@ export async function agentWorkflow(ticketId: string) { // One Arthur task per run: first run = ticket identifier, re-runs = identifier.N const arthurTaskId = await ensureArthurTaskForTicket(ticket.identifier); + // Per-ticket agent override via labels (e.g. `agent:codex`). Falls + // back to env.AGENT_KIND when the ticket has no override or the labels + // are ambiguous (multiple distinct kinds). + const { parseAgentKindOverride } = await import("../sandbox/agents/index.js"); + const agentKindOverride = parseAgentKindOverride(ticket.labels); + // Provision sandbox once for all phases - const sandboxId = await provisionSandbox(branchName, arthurTaskId, mergeBase); + const { sandboxId, agentKind } = await provisionSandbox(branchName, arthurTaskId, agentKindOverride, mergeBase); // Pin the sandboxId to this ticket so cleanup paths (reconcile, // cancelRun, webhook-cancel) can stop it by id instead of doing a // branch scan across every running sandbox. await registerTicketSandbox(ticket.identifier, sandboxId); + activeModel = agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL; + if (agentKind === "codex") { + const priceCache = await fetchCodexPriceStep(activeModel); + if (priceCache) priceLookup = () => priceCache; + } + try { await writeAttachments(sandboxId, downloadedAttachments); // ========== PHASE 1: Research & Plan ========== - await configureStopHook(sandboxId, false); + await setCommitGuardStep(sandboxId, agentKind, false); const ticketData = { identifier: ticket.identifier, @@ -421,6 +507,8 @@ export async function agentWorkflow(ticketId: string) { comments: ticket.comments, }; + const { paths: researchPaths, script: researchScript } = + await planPhaseStep(agentKind, "research", activeModel); const researchInput = assembleResearchPlanContext({ ticket: ticketData, prompt: prompts.research, @@ -431,22 +519,13 @@ export async function agentWorkflow(ticketId: string) { attachments: downloadedAttachments, }); - const researchScript = buildPhaseScript({ - model: env.CLAUDE_MODEL, - phase: "research", - inputFile: "/tmp/research-requirements.md", - outputFile: "/tmp/research-stdout.txt", - stderrFile: "/tmp/research-stderr.txt", - sentinelFile: "/tmp/research-done", - }); - await writeAndStartPhase( sandboxId, - "/tmp/research-requirements.md", researchInput, - "/tmp/research-wrapper.sh", researchScript, + researchPaths.input, researchInput, + researchPaths.wrapper, researchScript, ); - const researchDone = await pollUntilDone(sandboxId, "/tmp/research-done", 20); + const researchDone = await pollUntilDone(sandboxId, researchPaths.sentinel, 20); if (!researchDone) { await moveTicket(ticketId, env.COLUMN_BACKLOG); await notifySlack(`Task ${ticket.identifier} failed: research phase timed out${usageSuffix()}`); @@ -454,9 +533,11 @@ export async function agentWorkflow(ticketId: string) { return; } - const researchRaw = await collectPhaseOutput(sandboxId, "/tmp/research-stdout.txt", "/tmp/research-stderr.txt"); - phaseUsages["Research"] = extractUsage(researchRaw); - const research = parseResearchStatus(unwrapResearchText(researchRaw)); + const { raw: researchRaw, structured: researchStructured } = + await collectPhase(sandboxId, researchPaths); + const { research, usage: researchUsage } = + await parseResearchStep(agentKind, researchRaw, researchStructured); + phaseUsages["Research"] = researchUsage; if (research.status === "clarification_needed") { const questions = research.body.split("\n").filter((l) => /^\d+\./.test(l.trim())); @@ -481,8 +562,10 @@ export async function agentWorkflow(ticketId: string) { // ========== PHASE 2: Implementation ========== - await configureStopHook(sandboxId, true); + await setCommitGuardStep(sandboxId, agentKind, true); + const { paths: implPaths, script: implScript } = + await planPhaseStep(agentKind, "impl", activeModel, AGENT_SCHEMA); const implInput = assembleImplementationContext({ ticket: ticketData, prompt: prompts.implement, @@ -490,29 +573,20 @@ export async function agentWorkflow(ticketId: string) { attachments: downloadedAttachments, }); - const implScript = buildPhaseScript({ - model: env.CLAUDE_MODEL, - phase: "impl", - inputFile: "/tmp/impl-requirements.md", - outputFile: "/tmp/impl-stdout.txt", - stderrFile: "/tmp/impl-stderr.txt", - sentinelFile: "/tmp/impl-done", - jsonSchema: AGENT_SCHEMA, - }); - await writeAndStartPhase( sandboxId, - "/tmp/impl-requirements.md", implInput, - "/tmp/impl-wrapper.sh", implScript, + implPaths.input, implInput, + implPaths.wrapper, implScript, ); - const implDone = await pollUntilDone(sandboxId, "/tmp/impl-done", 35); + const implDone = await pollUntilDone(sandboxId, implPaths.sentinel, 35); let implOutput: AgentOutput; if (implDone) { - const implRaw = await collectPhaseOutput(sandboxId, "/tmp/impl-stdout.txt", "/tmp/impl-stderr.txt"); - phaseUsages["Impl"] = extractUsage(implRaw); - implOutput = parseAgentOutput(implRaw); + const { raw: implRaw, structured: implStructured } = await collectPhase(sandboxId, implPaths); + const { output, usage: implUsage } = await parseAgentOutputStep(agentKind, implRaw, implStructured); + phaseUsages["Impl"] = implUsage; + implOutput = output; } else { implOutput = { result: "failed", error: "Implementation phase timed out" }; } @@ -537,10 +611,11 @@ export async function agentWorkflow(ticketId: string) { // ========== PHASE 3: Review ========== // Temporarily disabled. - // await configureStopHook(sandboxId, true); + // await setCommitGuardStep(sandboxId, agentKind, true); // // const gitDiff = await captureGitDiff(sandboxId); // + // const reviewPaths = agent.artifactPaths("review"); // const reviewInput = assembleReviewContext({ // ticket: ticketData, // prompt: prompts.review, @@ -549,29 +624,26 @@ export async function agentWorkflow(ticketId: string) { // attachments: downloadedAttachments, // }); // - // const reviewScript = buildPhaseScript({ - // model: env.CLAUDE_MODEL, + // const reviewScript = agent.buildPhaseScript({ // phase: "review", - // inputFile: "/tmp/review-requirements.md", - // outputFile: "/tmp/review-stdout.txt", - // stderrFile: "/tmp/review-stderr.txt", - // sentinelFile: "/tmp/review-done", + // model: activeModel, + // paths: reviewPaths, // jsonSchema: REVIEW_SCHEMA, // }); // // await writeAndStartPhase( // sandboxId, - // "/tmp/review-requirements.md", reviewInput, - // "/tmp/review-wrapper.sh", reviewScript, + // reviewPaths.input, reviewInput, + // reviewPaths.wrapper, reviewScript, // ); // - // const reviewDone = await pollUntilDone(sandboxId, "/tmp/review-done", 15); + // const reviewDone = await pollUntilDone(sandboxId, reviewPaths.sentinel, 15); // let reviewOutput: ReviewOutput; // // if (reviewDone) { - // const reviewRaw = await collectPhaseOutput(sandboxId, "/tmp/review-stdout.txt", "/tmp/review-stderr.txt"); - // phaseUsages["Review"] = extractUsage(reviewRaw); - // reviewOutput = parseReviewOutput(reviewRaw); + // const { raw: reviewRaw, structured: reviewStructured } = await collectPhase(sandboxId, reviewPaths); + // phaseUsages["Review"] = agent.extractUsage(reviewRaw, reviewStructured); + // reviewOutput = agent.parseReviewOutput(reviewRaw, reviewStructured); // } else { // reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; // } @@ -586,7 +658,7 @@ export async function agentWorkflow(ticketId: string) { // ========== POST-PHASES: Push & PR ========== let pushResult = await pushFromSandbox(sandboxId, branchName); if (!pushResult.pushed && pushResult.error) { - pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error); + pushResult = await fixAndRetryPush(sandboxId, branchName, pushResult.error, agentKind, activeModel); } if (!pushResult.pushed) { @@ -602,7 +674,7 @@ export async function agentWorkflow(ticketId: string) { // Notify Slack BEFORE moving the ticket out of the AI column. // Reconcile cancels runs whose tickets have left AI column; racing // that cancellation after moveTicket would skip the notification. - const usageReport = formatUsageReport(phaseUsages); + const usageReport = formatUsageReport(phaseUsages, priceLookup, activeModel); await notifySlack(`Task ${ticket.identifier} PR ready for review\n${usageReport}`); await moveTicket(ticketId, env.COLUMN_AI_REVIEW); await unregisterRun(ticket.identifier); From 4875dcf740efa4f7ee564f445af29f4955df1a79 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Wed, 29 Apr 2026 08:55:28 +0200 Subject: [PATCH 70/71] fix: build --- src/workflows/agent.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index fda1b65..a02151d 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -387,6 +387,12 @@ async function registerTicketSandbox(ticketIdentifier: string, sandboxId: string await runRegistry.registerSandbox(ticketIdentifier, sandboxId); } +async function resolveAgentKindOverride(labels: readonly string[]): Promise { + "use step"; + const { parseAgentKindOverride } = await import("../sandbox/agents/index.js"); + return parseAgentKindOverride(labels); +} + async function markTicketFailed(ticketIdentifier: string, error: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); @@ -477,8 +483,7 @@ export async function agentWorkflow(ticketId: string) { // Per-ticket agent override via labels (e.g. `agent:codex`). Falls // back to env.AGENT_KIND when the ticket has no override or the labels // are ambiguous (multiple distinct kinds). - const { parseAgentKindOverride } = await import("../sandbox/agents/index.js"); - const agentKindOverride = parseAgentKindOverride(ticket.labels); + const agentKindOverride = await resolveAgentKindOverride(ticket.labels); // Provision sandbox once for all phases const { sandboxId, agentKind } = await provisionSandbox(branchName, arthurTaskId, agentKindOverride, mergeBase); From b66d517edd0118200bfbf17f80259f7fec7e73de Mon Sep 17 00:00:00 2001 From: kasin-it Date: Wed, 29 Apr 2026 09:59:13 +0200 Subject: [PATCH 71/71] fix: agent kill test --- e2e/helpers/jira.ts | 7 ++++++ e2e/helpers/sandbox.ts | 54 ++++++++++++++++++++++++++++++++---------- src/workflows/agent.ts | 20 +--------------- 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index 778854e..ee56278 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -102,6 +102,13 @@ export async function getTicketStatus(ticketKey: string): Promise { return data.fields.status.name; } +export async function getTicketLabels(ticketKey: string): Promise { + const data = await jiraRequest( + `/rest/api/3/issue/${ticketKey}?fields=labels`, + ); + return Array.isArray(data?.fields?.labels) ? data.fields.labels : []; +} + /** * Ask Jira's search API whether `ticketKey` is currently visible under the * given status. Unlike `/issue/{key}` (which returns committed state), the diff --git a/e2e/helpers/sandbox.ts b/e2e/helpers/sandbox.ts index 3e726a3..807676a 100644 --- a/e2e/helpers/sandbox.ts +++ b/e2e/helpers/sandbox.ts @@ -49,25 +49,55 @@ export async function stopSandboxesForTicket( } /** - * Kill the running `claude` process inside the ticket's sandbox. + * Kill the running agent process (claude or codex) inside the ticket's sandbox. * * The wrapper script's cleanup section (touch sentinel) runs unconditionally - * after claude exits, so killing claude causes the workflow's pollUntilDone - * to see the sentinel with empty/partial stdout — parseResearchStatus then - * defaults to `{ status: "failed" }`, exercising the US-7 failure path. + * after the agent exits, so killing the agent causes the workflow's + * pollUntilDone to see the sentinel with empty/partial stdout — + * parseResearchStatus then defaults to `{ status: "failed" }`, exercising the + * US-7 failure path. * - * Returns `true` only when `pkill` actually terminated a claude process. + * The pkill pattern matches a flag unique to the agent's wrapper invocation + * (`claude --print` / `codex exec`) rather than the bare binary name. This + * avoids false positives from the Arthur tracer hook (`claude_code_tracer.py`) + * which runs as a transient `python3` subprocess whose cmdline contains the + * substring "claude" — without this constraint, the previous `-f claude` + * pattern matched the tracer in codex sandboxes, returned exit 0, but never + * actually killed codex; the agent ran to completion and the ticket landed in + * AI Review instead of Backlog. + * + * Agent kind is resolved from the ticket's `agent:` label using the + * same `parseAgentKindOverride` the workflow runs server-side, so the helper + * targets whichever agent the deployed app actually started. + * + * Returns `true` only when `pkill` actually terminated a matching process. * Returning `true` from "sandbox exists on the right branch" alone is unsafe: - * there's a window between git checkout and claude exec where the wrapper is - * still sourcing env files — `pkill` then matches nothing (exit 1), claude - * starts a moment later, the agent runs to completion, and the ticket lands - * in AI Review instead of Backlog. Caller polls this helper, so returning - * `false` on a no-op pkill makes the caller try again instead of advancing. + * there's a window between git checkout and the agent exec where the wrapper + * is still sourcing env files — `pkill` then matches nothing (exit 1), the + * agent starts a moment later, and runs to completion. Caller polls this + * helper, so returning `false` on a no-op pkill makes the caller try again + * instead of advancing. */ export async function killClaudeForTicket( ticketKey: string, ): Promise { const expectedBranch = `blazebot/${ticketKey.trim().toLowerCase()}`; + const { getTicketLabels } = await import("./jira.js"); + const { parseAgentKindOverride } = await import( + "../../src/sandbox/agents/index.js" + ); + const labels = await getTicketLabels(ticketKey).catch(() => [] as string[]); + const labelKind = parseAgentKindOverride(labels); + // Fall back to the same default the deployed app uses when no agent:* label + // is present (env.AGENT_KIND, default "claude"). + const envFallback = + process.env.E2E_AGENT_KIND?.toLowerCase() === "codex" ? "codex" : "claude"; + const agentKind = labelKind ?? envFallback; + // Pattern targets a flag that appears only in the agent's wrapper-script + // invocation, never in the Arthur tracer's argv. See claude.ts/codex.ts + // buildPhaseScript for the exact command line. + const killPattern = agentKind === "codex" ? "codex exec" : "claude --print"; + const { Sandbox } = await import("@vercel/sandbox"); const { getSandboxCredentials } = await import( "../../src/sandbox/credentials.js" @@ -101,10 +131,10 @@ export async function killClaudeForTicket( // (touch sentinel with empty stdout) will run. const killResult = await sandbox.runCommand({ cmd: "pkill", - args: ["-9", "-f", "claude"], + args: ["-9", "-f", killPattern], }); if (killResult.exitCode === 0) return true; - // Matched sandbox but claude wasn't running yet; caller will retry. + // Matched sandbox but agent wasn't running yet; caller will retry. return false; } return false; diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index a02151d..786fc3a 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -1,6 +1,6 @@ import { sleep } from "workflow"; import type { - AgentOutput, ReviewOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, + AgentOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, } from "../sandbox/agents/types.js"; import type { AgentKind } from "../sandbox/agents/index.js"; import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; @@ -321,24 +321,6 @@ async function parseAgentOutputStep( return { output: a.parseAgentOutput(raw, structured), usage: a.extractUsage(raw, structured) }; } -async function captureGitDiff(sandboxId: string): Promise { - "use step"; - const { Sandbox } = await import("@vercel/sandbox"); - const { getSandboxCredentials } = await import("../sandbox/credentials.js"); - - const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - const baseShaResult = await sandbox.runCommand("bash", [ - "-c", "cat /tmp/.pre-agent-sha 2>/dev/null || echo ''", - ]); - const baseSha = (await baseShaResult.stdout()).trim(); - - const diffCmd = baseSha - ? `git diff ${baseSha}..HEAD` - : "git diff HEAD"; - const diffResult = await sandbox.runCommand("bash", ["-c", diffCmd]); - return (await diffResult.stdout()).trim(); -} - async function createPullRequest(branchName: string, title: string, summary: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js");