diff --git a/infra/emdash-bot/.flue/agents/investigate.ts b/infra/emdash-bot/.flue/agents/investigate.ts index e28ac1cb62..85275e7d53 100644 --- a/infra/emdash-bot/.flue/agents/investigate.ts +++ b/infra/emdash-bot/.flue/agents/investigate.ts @@ -1,9 +1,9 @@ "use agent"; +import { getWorkspace, type WorkspaceStubHost } from "@cloudflare/computer"; import { getSandbox } from "@cloudflare/sandbox"; import { defineTool, - defineSkill, type AgentProps, useAgentFinish, useAgentStart, @@ -11,14 +11,18 @@ import { useInitialData, useModel, usePersistentState, - useSandbox, useSkill, useTool, } from "@flue/runtime"; -import { cloudflareSandbox } from "@flue/runtime/cloudflare"; import { env as workerEnv } from "cloudflare:workers"; import * as v from "valibot"; +import { + type ContainerBackend, + ExecEnv, + fromSandbox, + fromWorkspaceClient, +} from "../lib/exec-env.js"; import { createPushCapability, PUSH_CAPABILITY_HEADER } from "../lib/github-proxy.js"; import { getBranchSha, @@ -27,12 +31,19 @@ import { readRepoContext, } from "../lib/github.js"; import { applyInvestigationResult } from "../lib/investigation-result.js"; -import { withSandboxDeadlines } from "../lib/sandbox-deadline.js"; -import investigateDocument from "../skills/investigate/instructions.md?raw"; +import diagnoseSkill from "../skills/diagnose/SKILL.md"; +import fixSkill from "../skills/fix/SKILL.md"; +import investigateSkill from "../skills/investigate/SKILL.md"; +import reproAdminSkill from "../skills/repro-admin/SKILL.md"; +import reproApiSkill from "../skills/repro-api/SKILL.md"; +import reproPublicSkill from "../skills/repro-public/SKILL.md"; +import verifySkill from "../skills/verify/SKILL.md"; const REPO_DIR = "/workspace/repo"; -const DEFAULT_SANDBOX_RPC_TIMEOUT_MS = 2 * 60_000; -const SANDBOX_EXEC_GRACE_MS = 30_000; +const DEFAULT_RPC_TIMEOUT_MS = 2 * 60_000; +const EXEC_GRACE_MS = 30_000; +const CLONE_DEPTH = 50; +const DEADLINES = { defaultTimeoutMs: DEFAULT_RPC_TIMEOUT_MS, execGraceMs: EXEC_GRACE_MS }; const initialDataSchema = v.object({ runId: v.pipe(v.string(), v.minLength(1)), @@ -61,47 +72,138 @@ const reportedResultSchema = v.object({ type InvestigateData = v.InferOutput; type InvestigationResult = v.InferOutput; -const investigate = defineSkill({ - name: "investigate", - description: - "Investigate an EmDash issue, verify the result, and push a fix branch when appropriate.", - instructions: investigateDocument.trim(), -}); - export function Investigate({ id }: AgentProps) { const input = useInitialData(); const [setupComplete, setSetupComplete] = usePersistentState("setup-complete", false); const [reported, setReported] = usePersistentState("reported", false); const [reminded, setReminded] = usePersistentState("report-reminded", false); const writeResult = useDataWriter("investigation", { schema: reportedResultSchema }); - const sandbox = getSandbox(workerEnv.Sandbox, id); + const env = execEnvFor(id, input); useModel("cloudflare/@cf/moonshotai/kimi-k2.7-code"); - useSandbox( - withSandboxDeadlines(cloudflareSandbox(sandbox), { - defaultTimeoutMs: DEFAULT_SANDBOX_RPC_TIMEOUT_MS, - execGraceMs: SANDBOX_EXEC_GRACE_MS, - }), - { cwd: REPO_DIR }, - ); - useSkill(investigate); - useAgentStart(async ({ harness, log }) => { + useSkill(investigateSkill); + useSkill(diagnoseSkill); + useSkill(verifySkill); + useSkill(reproApiSkill); + useSkill(reproAdminSkill); + useSkill(reproPublicSkill); + // Every mode but diagnose may end in a fix attempt (`repro` is + // "reproduce and attempt a fix" in machine.ts). + if (input.mode !== "diagnose") { + useSkill(fixSkill); + } + + useAgentStart(async ({ log }) => { if (setupComplete || reported) return; try { - await setupSandbox(harness, input, log); + await env.cloneRepo({ + url: cloneUrl(), + dir: REPO_DIR, + ref: cloneRef(input), + depth: CLONE_DEPTH, + }); setSetupComplete(true); } catch (error) { const result = failedResult( - `I couldn't prepare the investigation sandbox: ${errorMessage(error)}`, + `I couldn't prepare the investigation workspace: ${errorMessage(error)}`, ); await applyInvestigationResult(input, result, false, false); writeResult({ result, ok: false, pushed: false }); setReported(true); - log.error("sandbox setup failed", { error: errorMessage(error) }); + log.error("workspace setup failed", { error: errorMessage(error) }); } }); + useTool( + defineTool({ + name: "read_file", + description: "Read a file from the workspace (VFS). Prefer this over shelling out to `cat`.", + input: v.object({ path: v.string() }), + async run({ data }) { + return await env.readFile(data.path); + }, + }), + ); + + useTool( + defineTool({ + name: "write_file", + description: "Write (create or overwrite) a file in the workspace.", + input: v.object({ path: v.string(), content: v.string() }), + async run({ data }) { + await env.writeFile(data.path, data.content); + return `wrote ${data.path}`; + }, + }), + ); + + useTool( + defineTool({ + name: "edit_file", + description: "Replace an exact, unique substring in a file.", + input: v.object({ path: v.string(), oldString: v.string(), newString: v.string() }), + async run({ data }) { + await env.edit(data.path, data.oldString, data.newString); + return `edited ${data.path}`; + }, + }), + ); + + useTool( + defineTool({ + name: "ls", + description: "List a directory in the workspace.", + input: v.object({ path: v.string() }), + async run({ data }) { + const entries = await env.ls(data.path); + return entries.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); + }, + }), + ); + + useTool( + defineTool({ + name: "grep", + description: "Search the workspace for a pattern. Fast; runs in the isolate.", + input: v.object({ + pattern: v.string(), + path: v.string(), + ignoreCase: v.optional(v.boolean()), + }), + async run({ data }) { + const matches = await env.grep( + data.pattern, + data.path, + data.ignoreCase === undefined ? undefined : { ignoreCase: data.ignoreCase }, + ); + return matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join("\n") || "(no matches)"; + }, + }), + ); + + useTool( + defineTool({ + name: "exec", + description: + "Run a shell command. target 'isolate' (default) is fast bash-in-isolate for grep/git/inspection; target 'container' attaches a Linux container for pnpm/astro/vitest/agent-browser -- slow, use only to run the project.", + input: v.object({ + command: v.string(), + target: v.optional(v.picklist(["isolate", "container"]), "isolate"), + cwd: v.optional(v.string()), + timeoutMs: v.optional(v.number()), + }), + async run({ data }) { + const result = await env.exec(data.command, { + target: data.target, + ...(data.cwd ? { cwd: data.cwd } : {}), + ...(data.timeoutMs ? { timeoutMs: data.timeoutMs } : {}), + }); + return [`exit ${result.exitCode}`, result.stdout, result.stderr].filter(Boolean).join("\n"); + }, + }), + ); + useTool( defineTool({ name: "report_result", @@ -154,7 +256,7 @@ export function Investigate({ id }: AgentProps) { }); if (reported && !setupComplete) { - return "Sandbox setup failed and the failure has already been reported. Briefly acknowledge that the run could not start."; + return "Workspace setup failed and the failure has already been reported. Briefly acknowledge that the run could not start."; } return buildPrompt(input); @@ -164,17 +266,87 @@ Investigate.agentName = "investigate"; Investigate.initialData = initialDataSchema; Investigate.durability = { maxAttempts: 5, timeoutMs: 30 * 60_000 }; -async function setupSandbox( - harness: Parameters[0]>[0]["harness"], - input: InvestigateData, - log: Parameters[0]>[0]["log"], -): Promise { +/** + * Per-run ExecEnv, cached on `globalThis` so it survives the agent's re-renders + * within one isolate (Vite duplicates modules across SSR chunks, so a plain + * module `let` would not be shared). The container is attached lazily on first + * container exec; the VFS/isolate side needs no attach. + */ +const EXEC_ENV_REGISTRY = Symbol.for("emdash-bot.execEnvs"); + +function execEnvRegistry(): Map { + const store = globalThis as typeof globalThis & { [EXEC_ENV_REGISTRY]?: Map }; + return (store[EXEC_ENV_REGISTRY] ??= new Map()); +} + +function execEnvFor(id: string, input: InvestigateData): ExecEnv { + const registry = execEnvRegistry(); + const existing = registry.get(id); + if (existing) return existing; + let clientPromise: ReturnType | undefined; + const isolate = fromWorkspaceClientLazy(async () => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Wrangler cannot infer the withWorkspace stub-host type. + const stub = workerEnv.WorkspaceDO.get( + workerEnv.WorkspaceDO.idFromName(id), + ) as unknown as WorkspaceStubHost; + try { + return await (clientPromise ??= getWorkspace(stub)); + } catch (error) { + // A rejected promise must not stay cached: the next call retries. + clientPromise = undefined; + throw error; + } + }); + const env = new ExecEnv({ + isolate, + attachContainer: () => attachContainer(id, input), + deadlines: DEADLINES, + repoDir: REPO_DIR, + }); + registry.set(id, env); + return env; +} + +/** Defer resolving the RPC client until the first fs/runtime call. */ +function fromWorkspaceClientLazy(getClient: () => ReturnType) { + let backend: ReturnType | undefined; + const resolve = async () => (backend ??= fromWorkspaceClient(await getClient())); + return { + fs: { + readFile: async (path: string, encoding: "utf8") => + (await resolve()).fs.readFile(path, encoding), + writeFile: async (path: string, content: string) => + (await resolve()).fs.writeFile(path, content), + mkdir: async (path: string, options?: { recursive?: boolean }) => + (await resolve()).fs.mkdir(path, options), + readdir: async (path: string) => (await resolve()).fs.readdir(path), + rm: async (path: string, options?: { recursive?: boolean; force?: boolean }) => + (await resolve()).fs.rm(path, options), + grep: async (pattern: string, path: string, options?: { ignoreCase?: boolean }) => + (await resolve()).fs.grep(pattern, path, options), + }, + runtime: { + exec: async ( + source: string, + options: { backend?: string; cwd?: string; encoding: "utf8"; timeoutMs?: number }, + ) => (await resolve()).runtime.exec(source, options), + }, + }; +} + +/** + * Attach the container substrate and reproduce the base checkout the toolchain + * runs against: git identity, a clone (or fetch) at the run's ref, and the + * issue-scoped push capability the outbound proxy verifies. pnpm install is + * left to the repro/fix skills -- isolate-first, container work on demand. + */ +async function attachContainer(id: string, input: InvestigateData): Promise { + const container = fromSandbox(getSandbox(workerEnv.Sandbox, id)); const repo = readRepoContext(workerEnv); if (!repo) throw new Error("repository context is not configured"); - const cloneUrl = `https://github.com/${repo.owner}/${repo.repo}.git`; - const branch = input.mode === "revise" ? `bot/fix-${input.issueNumber}` : "main"; + const branch = cloneRef(input); // Diagnose mode is investigation-only: no push capability enters the - // sandbox, so a fix push is impossible rather than merely instructed against. + // container, so a fix push is impossible rather than merely instructed against. const pushCapability = input.mode === "diagnose" ? null @@ -184,69 +356,45 @@ async function setupSandbox( repo.repo, input.issueNumber, ); - const steps: Array<{ name: string; command: string; timeoutMs?: number; nonFatal?: boolean }> = [ + const steps: Array<{ command: string; timeoutMs?: number }> = [ + { command: 'git config --global user.email "emdashbot[bot]@users.noreply.github.com"' }, + { command: 'git config --global user.name "emdashbot[bot]"' }, + { command: "mkdir -p /workspace" }, { - name: "git-identity-email", - command: 'git config --global user.email "emdashbot[bot]@users.noreply.github.com"', + command: `if [ -d ${REPO_DIR}/.git ]; then cd ${REPO_DIR} && git fetch --all --prune; else git clone --depth ${CLONE_DEPTH} --branch '${branch}' '${cloneUrl()}' ${REPO_DIR}; fi`, + timeoutMs: 5 * 60_000, }, - { name: "git-identity-name", command: 'git config --global user.name "emdashbot[bot]"' }, - { name: "mkdir-workspace", command: "mkdir -p /workspace" }, { - name: "clone-or-fetch", - command: `if [ -d ${REPO_DIR}/.git ]; then cd ${REPO_DIR} && git fetch --all --prune; else git clone --depth 50 '${cloneUrl}' ${REPO_DIR}; fi`, - timeoutMs: 5 * 60_000, + command: `cd ${REPO_DIR} && git checkout '${branch}' && git reset --hard 'origin/${branch}'`, }, - input.mode === "revise" - ? { - name: "checkout-revise", - command: `cd ${REPO_DIR} && git fetch origin '${branch}':'refs/remotes/origin/${branch}' && git checkout '${branch}'`, - } - : { - name: "checkout-main", - command: `cd ${REPO_DIR} && git checkout main && git reset --hard origin/main`, - }, ...(pushCapability ? [ { - name: "git-push-capability", command: `cd ${REPO_DIR} && git config http.https://github.com/.extraHeader '${PUSH_CAPABILITY_HEADER}: ${pushCapability}'`, }, ] : []), - { - name: "pnpm-install", - command: `cd ${REPO_DIR} && pnpm install --frozen-lockfile --prefer-offline`, - timeoutMs: 15 * 60_000, - nonFatal: true, - }, ]; - - for (const setupStep of steps) { - try { - const result = await harness.sandbox.exec(setupStep.command, { - cwd: "/", - ...(setupStep.timeoutMs ? { timeoutMs: setupStep.timeoutMs } : {}), - }); - if (result.exitCode === 0) { - log.info(`setupSandbox: ${setupStep.name} ok`); - continue; - } - const message = `${setupStep.name} exited ${result.exitCode}: ${result.stderr.slice(-500)}`; - if (setupStep.nonFatal) { - log.warn(message); - continue; - } - throw new Error(message); - } catch (error) { - if (setupStep.nonFatal) { - log.warn(`setupSandbox: ${setupStep.name} failed non-fatally`, { - error: errorMessage(error), - }); - continue; - } - throw error; + for (const step of steps) { + const result = await container.exec(step.command, { + cwd: "/", + ...(step.timeoutMs ? { timeoutMs: step.timeoutMs } : {}), + }); + if (result.exitCode !== 0) { + throw new Error(`container setup failed (${result.exitCode}): ${result.stderr.slice(-500)}`); } } + return container; +} + +function cloneUrl(): string { + const repo = readRepoContext(workerEnv); + if (!repo) throw new Error("repository context is not configured"); + return `https://github.com/${repo.owner}/${repo.repo}.git`; +} + +function cloneRef(input: InvestigateData): string { + return input.mode === "revise" ? `bot/fix-${input.issueNumber}` : "main"; } function failedResult(summary: string): InvestigationResult { @@ -259,7 +407,7 @@ function failedResult(summary: string): InvestigationResult { } function truncateSummary(text: string): string { - return text.length <= 400 ? text : `${text.slice(0, 399)}\u2026`; + return text.length <= 400 ? text : `${text.slice(0, 399)}…`; } function errorMessage(error: unknown): string { @@ -288,7 +436,7 @@ function buildPrompt(input: InvestigateData): string { "- Read AGENTS.md, find the relevant code, attempt to reproduce, build, or revise.", "- Write tests where they make sense.", "- Touch only files relevant to the issue. Do not bulk-format or modify .github/workflows.", - `- When done, commit and push: \`git checkout -B bot/fix-${input.issueNumber} && git add && git commit -m '' && git push -u origin HEAD --force-with-lease\`.`, + `- When done, commit and push from a container: \`exec\` with target container running \`git checkout -B bot/fix-${input.issueNumber} && git add && git commit -m '' && git push -u origin HEAD --force-with-lease\`.`, ]; const closing = diagnose ? "Call report_result exactly once when finished. Do not set fixed; report reproduced and your verdict with the diagnosis in summary." diff --git a/infra/emdash-bot/.flue/cloudflare.ts b/infra/emdash-bot/.flue/cloudflare.ts index 9c36e804c9..c6ad041b83 100644 --- a/infra/emdash-bot/.flue/cloudflare.ts +++ b/infra/emdash-bot/.flue/cloudflare.ts @@ -1,8 +1,13 @@ // Cloudflare-target Durable Object exports. Flue's Vite plugin composes these // user-owned classes with its generated agent classes in the final Worker. +import { type DurableObjectStorageLike, withWorkspace } from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createGitClient } from "@cloudflare/computer/git"; import { Sandbox as BaseSandbox } from "@cloudflare/sandbox"; +import { DurableObject } from "cloudflare:workers"; +import { ISOLATE_SHELL_BACKEND } from "./lib/exec-env.js"; import { gateGithubRequest, githubAuthHeader, @@ -117,3 +122,36 @@ function errorMessage(error: unknown): string { export { ContainerProxy } from "@cloudflare/sandbox"; export { OrchestratorDO } from "./lib/orchestrator.js"; + +// Isolate + VFS substrate for execEnv's `IsolateBackend`: a +// @cloudflare/computer Workspace on a SQLite DO. `fs` and the built-in `git` +// command back the read/grep/inspect path; the worker-shell backend runs +// isolate exec in a Dynamic Worker via the LOADER binding. The container half +// stays on `Sandbox` above; exec-env.ts owns that seam. +// +// `ctx`/`env` are re-exposed publicly because the mixin's options callback +// reads them from outside the class body, where the base's protected members +// are unreachable. +class WorkspaceBase extends DurableObject { + get doCtx(): DurableObjectState { + return this.ctx; + } + get doEnv(): Env { + return this.env; + } +} + +export class WorkspaceDO extends withWorkspace(WorkspaceBase, (self) => ({ + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-assertion -- platform DurableObjectStorage satisfies computer's narrowed Like type at runtime; the unnecessary-assertion rule misfires when the generated worker types are absent. + storage: self.doCtx.storage as unknown as DurableObjectStorageLike, + git: createGitClient(), + waitUntil: self.doCtx.waitUntil.bind(self.doCtx), + backends: [ + new WorkerShellBackend({ + id: ISOLATE_SHELL_BACKEND, + loader: self.doEnv.LOADER, + workspace: { binding: "WorkspaceDO", id: self.doCtx.id.toString() }, + ctx: self.doCtx, + }), + ], +})) {} diff --git a/infra/emdash-bot/.flue/lib/exec-env.ts b/infra/emdash-bot/.flue/lib/exec-env.ts new file mode 100644 index 0000000000..6f4d9c7a0b --- /dev/null +++ b/infra/emdash-bot/.flue/lib/exec-env.ts @@ -0,0 +1,380 @@ +// execEnv: the single seam over the investigation's two execution substrates. +// Every @cloudflare/computer and @cloudflare/sandbox touchpoint lives here. +// +// - Isolate + VFS: @cloudflare/computer `Workspace` (fs + worker-shell +// exec). Holds the repo clone and every agent edit. Reads/greps/git run +// here without a container. +// - Container: @cloudflare/sandbox. Runs the toolchain (pnpm, astro, vitest, +// agent-browser). +// +// The VFS is authoritative for source: before every container exec, the +// container's working tree is re-synced from the VFS via `git status` against +// the checkout -- never from in-memory bookkeeping -- so an edit is +// materialized whether it was made before or after the container attached, +// and in this isolate or a resumed one. Container-only files (node_modules, +// build output) are untracked in the VFS and never touched. The one-time +// `git reset` that seeds the container checkout is owned by the injected +// `attachContainer`, which runs once. +// +// The VFS clone is unauthenticated; token minting is confined to the +// container's fix-push through the proxy. + +import type { WorkspaceClient } from "@cloudflare/computer"; +import type { Sandbox } from "@cloudflare/sandbox"; + +import { withDeadline } from "./sandbox-deadline.js"; + +export type ExecTarget = "isolate" | "container"; + +export interface ExecResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface ExecOptions { + readonly target: ExecTarget; + readonly cwd?: string; + readonly timeoutMs?: number; +} + +export interface GrepMatch { + readonly path: string; + readonly line: number; + readonly text: string; +} + +export interface CloneOptions { + readonly url: string; + readonly dir: string; + readonly ref?: string; + readonly depth?: number; +} + +export interface ExecEnvDeadlines { + /** Ceiling for fs/git RPCs and for an exec with no explicit timeout. */ + readonly defaultTimeoutMs: number; + /** Added to an exec's own timeout so the substrate kills before we do. */ + readonly execGraceMs: number; +} + +/** + * Isolate + VFS substrate. A structural subset of computer's `getWorkspace()` + * client (`fs` + `runtime` reach the DO over RPC through their stubs); + * `fromWorkspaceClient` adapts the real client, tests pass a fake. + */ +export interface IsolateBackend { + readonly fs: { + readFile(path: string, encoding: "utf8"): Promise; + writeFile(path: string, content: string): Promise; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + readdir(path: string): Promise>; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + grep(pattern: string, path: string, options?: { ignoreCase?: boolean }): Promise; + }; + readonly runtime: { + exec( + source: string, + options: { backend?: string; cwd?: string; encoding: "utf8"; timeoutMs?: number }, + ): Promise; + }; +} + +/** Minimal view of computer's `WorkspaceRuntimeExecHandle`. */ +export interface IsolateExecHandle { + result(): Promise<{ exitCode: number; stdout: string; stderr: string }>; + [Symbol.dispose]?(): void; +} + +/** + * Container substrate. A structural subset of @cloudflare/sandbox's session; + * `fromSandbox` adapts the real sandbox, tests pass a fake. + */ +export interface ContainerBackend { + exec( + command: string, + options?: { cwd?: string; timeoutMs?: number }, + ): Promise<{ exitCode: number; stdout: string; stderr: string }>; + writeFile(path: string, content: string): Promise; + readFileBytes(path: string): Promise; +} + +/** Backend id the isolate shell registers under (WorkerShellBackend). */ +export const ISOLATE_SHELL_BACKEND = "worker-shell"; + +const PATH_SEPARATOR = /[/\\]/; + +export interface ExecEnvOptions { + readonly isolate: IsolateBackend; + /** Lazily attaches the container; called at most once, result reused. */ + readonly attachContainer: () => Promise; + readonly deadlines: ExecEnvDeadlines; + /** Working-tree root, shared by both substrates (e.g. /workspace/repo). */ + readonly repoDir: string; +} + +export class ExecEnv { + readonly #isolate: IsolateBackend; + readonly #attachContainer: () => Promise; + readonly #deadlines: ExecEnvDeadlines; + readonly #repoDir: string; + #containerPromise: Promise | undefined; + + constructor(options: ExecEnvOptions) { + this.#isolate = options.isolate; + this.#attachContainer = options.attachContainer; + this.#deadlines = options.deadlines; + this.#repoDir = options.repoDir; + } + + /** + * Clone the repo into the VFS for isolate inspection and edit tracking. + * Runs through the worker-shell `git` command, which the DO's in-VFS + * isomorphic-git services -- no auth, since the repo is public. + */ + async cloneRepo(options: CloneOptions): Promise { + if (await this.#hasUsableClone(options.dir)) return; + const args = ["git", "clone", "--depth", String(options.depth ?? 50)]; + if (options.ref) args.push("--branch", options.ref); + args.push(quote(options.url), quote(options.dir)); + const result = await this.exec(args.join(" "), { target: "isolate" }); + if (result.exitCode !== 0) { + throw new Error(`git clone failed (${result.exitCode}): ${result.stderr.slice(-500)}`); + } + } + + /** + * The durable VFS may hold a clone from an earlier attempt -- but only + * trust one git can actually read. A partial clone is removed so the + * caller re-clones. + */ + async #hasUsableClone(dir: string): Promise { + try { + await this.#bounded(this.#isolate.fs.readdir(`${dir}/.git`), "readdir"); + } catch { + return false; + } + const probe = await this.exec("git status --porcelain", { target: "isolate", cwd: dir }); + if (probe.exitCode === 0) return true; + await this.#bounded(this.#isolate.fs.rm(dir, { recursive: true, force: true }), "rm"); + return false; + } + + readFile(path: string): Promise { + return this.#bounded(this.#isolate.fs.readFile(path, "utf8"), "readFile"); + } + + writeFile(path: string, content: string): Promise { + return this.#bounded(this.#isolate.fs.writeFile(path, content), "writeFile"); + } + + /** Replace an exact substring; the file must contain it exactly once. */ + async edit(path: string, oldString: string, newString: string): Promise { + const current = await this.readFile(path); + if (!current.includes(oldString)) throw new Error(`edit target not found in ${path}`); + const first = current.indexOf(oldString); + if (current.slice(first + oldString.length).includes(oldString)) { + throw new Error(`edit target is not unique in ${path}`); + } + await this.writeFile( + path, + current.slice(0, first) + newString + current.slice(first + oldString.length), + ); + } + + ls(path: string): Promise> { + return this.#bounded(this.#isolate.fs.readdir(path), "readdir"); + } + + grep(pattern: string, path: string, options?: { ignoreCase?: boolean }): Promise { + return this.#bounded(this.#isolate.fs.grep(pattern, path, options), "grep"); + } + + async exec(command: string, options: ExecOptions): Promise { + const timeoutMs = options.timeoutMs; + const deadlineMs = timeoutMs + ? timeoutMs + this.#deadlines.execGraceMs + : this.#deadlines.defaultTimeoutMs; + const cwd = options.cwd ?? this.#repoDir; + if (options.target === "isolate") { + return this.#execIsolate(command, cwd, timeoutMs, deadlineMs); + } + const container = await this.container(); + await this.#materializeVfsChanges(container); + return withDeadline( + container.exec(command, { cwd, ...(timeoutMs ? { timeoutMs } : {}) }), + deadlineMs, + "container exec", + ); + } + + /** + * Attach the container once and reuse it. Attach owns the one-time base + * checkout (via the injected `attachContainer`); working-tree sync is done + * per exec by `#materializeVfsChanges`, not here. + */ + container(): Promise { + return (this.#containerPromise ??= this.#attachContainer()); + } + + /** + * Read a container-produced artifact (a screenshot) for egress. `name` is a + * bare filename under `/.bot-artifacts/`; a path separator, `.`, `..`, + * or an absolute form is rejected and a symlink is refused, so a name can't + * escape the artifacts directory. + */ + async readArtifact(name: string): Promise { + if (name === "" || name === "." || name === ".." || PATH_SEPARATOR.test(name)) { + throw new Error(`invalid artifact name: ${name}`); + } + const path = `${this.#repoDir}/.bot-artifacts/${name}`; + const container = await this.container(); + const check = await this.#bounded( + container.exec(`test -f ${quote(path)} && test ! -L ${quote(path)}`), + "readArtifact check", + ); + if (check.exitCode !== 0) throw new Error(`artifact is not a regular file: ${name}`); + return this.#bounded(container.readFileBytes(path), "readArtifact"); + } + + async #execIsolate( + command: string, + cwd: string, + timeoutMs: number | undefined, + deadlineMs: number, + ): Promise { + const handle = await withDeadline( + this.#isolate.runtime.exec(command, { + backend: ISOLATE_SHELL_BACKEND, + encoding: "utf8", + cwd, + ...(timeoutMs ? { timeoutMs } : {}), + }), + this.#deadlines.defaultTimeoutMs, + "isolate exec start", + ); + try { + const result = await withDeadline(handle.result(), deadlineMs, "isolate exec"); + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; + } finally { + handle[Symbol.dispose]?.(); + } + } + + /** + * Bring the container's working tree in line with the VFS. The change set is + * re-derived from the VFS on every call (`git status` against the checkout), + * so no edit is missed regardless of when or in which isolate it was made. + */ + async #materializeVfsChanges(container: ContainerBackend): Promise { + const status = await this.exec("git status --porcelain -z --untracked-files=all", { + target: "isolate", + cwd: this.#repoDir, + }); + if (status.exitCode !== 0) { + throw new Error(`git status failed (${status.exitCode}): ${status.stderr.slice(-500)}`); + } + for (const change of parsePorcelain(status.stdout)) { + const path = `${this.#repoDir}/${change.path}`; + if (change.op === "delete") { + await this.#bounded(container.exec(`rm -f -- ${quote(path)}`), "materialize rm"); + continue; + } + const content = await this.#bounded( + this.#isolate.fs.readFile(path, "utf8"), + "materialize read", + ); + await this.#bounded(container.writeFile(path, content), "materialize write"); + } + } + + #bounded(operation: Promise, label: string): Promise { + return withDeadline(operation, this.#deadlines.defaultTimeoutMs, label); + } +} + +/** + * Adapt the computer `getWorkspace()` client. The only structural computer + * touchpoint. `fs` and `runtime` reach the DO over RPC through their stubs; the + * seam therefore runs agent-side, not in the DO. + */ +export function fromWorkspaceClient(client: WorkspaceClient): IsolateBackend { + return { + fs: { + readFile: (path, encoding) => client.fs.readFile(path, encoding), + writeFile: (path, content) => client.fs.writeFile(path, content), + mkdir: (path, options) => client.fs.mkdir(path, options), + readdir: (path) => client.fs.readdir(path), + rm: (path, options) => client.fs.rm(path, options), + grep: (pattern, path, options) => client.fs.grep(pattern, path, options), + }, + runtime: { + exec: (source, options) => client.runtime.exec(source, options), + }, + }; +} + +/** Single-quote a shell argument for the isolate command line. */ +function quote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +interface VfsChange { + readonly path: string; + readonly op: "materialize" | "delete"; +} + +/** + * Parse `git status --porcelain -z` into per-path sync ops. The `-z` format is + * NUL-delimited and never quotes or C-escapes paths, so special characters and + * spaces are carried verbatim. A rename/copy entry (`R`/`C`) is followed by a + * second NUL field carrying the old path (new-path-then-old-path order): a + * rename deletes the old path and materializes the new, a copy only + * materializes. A `D` in either status column deletes; everything else + * (modified, added, untracked) materializes. + */ +function parsePorcelain(output: string): VfsChange[] { + const fields = output.split("\0"); + const changes: VfsChange[] = []; + let i = 0; + while (i < fields.length) { + const field = fields[i]; + i += 1; + if (field === undefined || field.length < 4) continue; + const index = field[0]; + const worktree = field[1]; + const path = field.slice(3); + if (index === "R" || index === "C" || worktree === "R" || worktree === "C") { + const oldPath = fields[i]; + i += 1; + if ((index === "R" || worktree === "R") && oldPath) { + changes.push({ path: oldPath, op: "delete" }); + } + changes.push({ path, op: "materialize" }); + continue; + } + const deleted = index === "D" || worktree === "D"; + changes.push({ path, op: deleted ? "delete" : "materialize" }); + } + return changes; +} + +/** Adapt the real sandbox. The only structural sandbox touchpoint. */ +export function fromSandbox(sandbox: Sandbox): ContainerBackend { + return { + async exec(command, options) { + const result = await sandbox.exec(command, { + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), + }); + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; + }, + async writeFile(path, content) { + await sandbox.writeFile(path, content); + }, + async readFileBytes(path) { + const stream = await sandbox.readFileStream(path); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }, + }; +} diff --git a/infra/emdash-bot/.flue/lib/sandbox-deadline.ts b/infra/emdash-bot/.flue/lib/sandbox-deadline.ts index f70b0b3d8a..082a7ea356 100644 --- a/infra/emdash-bot/.flue/lib/sandbox-deadline.ts +++ b/infra/emdash-bot/.flue/lib/sandbox-deadline.ts @@ -1,10 +1,3 @@ -import type { SandboxFactory, SessionEnv } from "@flue/runtime"; - -interface SandboxDeadlineOptions { - defaultTimeoutMs: number; - execGraceMs: number; -} - export class DeadlineExceededError extends Error { constructor(label: string, timeoutMs: number) { super(`${label} timed out after ${timeoutMs}ms`); @@ -30,44 +23,3 @@ export async function withDeadline( if (timer !== undefined) clearTimeout(timer); } } - -export function withSandboxDeadlines( - factory: SandboxFactory, - options: SandboxDeadlineOptions, -): SandboxFactory { - return { - ...factory, - async createSandbox(context) { - const env = await withDeadline( - factory.createSandbox(context), - options.defaultTimeoutMs, - "Sandbox session creation", - ); - return wrapSessionEnv(env, options); - }, - }; -} - -function wrapSessionEnv(env: SessionEnv, options: SandboxDeadlineOptions): SessionEnv { - const bounded = (operation: PromiseLike, operationName: string) => - withDeadline(operation, options.defaultTimeoutMs, `Sandbox ${operationName}`); - - return { - exec(command, execOptions) { - const timeoutMs = execOptions?.timeoutMs - ? execOptions.timeoutMs + options.execGraceMs - : options.defaultTimeoutMs; - return withDeadline(env.exec(command, execOptions), timeoutMs, "Sandbox exec"); - }, - readFile: (path) => bounded(env.readFile(path), "readFile"), - readFileBuffer: (path) => bounded(env.readFileBuffer(path), "readFileBuffer"), - writeFile: (path, content) => bounded(env.writeFile(path, content), "writeFile"), - stat: (path) => bounded(env.stat(path), "stat"), - readdir: (path) => bounded(env.readdir(path), "readdir"), - exists: (path) => bounded(env.exists(path), "exists"), - mkdir: (path, mkdirOptions) => bounded(env.mkdir(path, mkdirOptions), "mkdir"), - rm: (path, rmOptions) => bounded(env.rm(path, rmOptions), "rm"), - cwd: env.cwd, - resolvePath: (path) => env.resolvePath(path), - }; -} diff --git a/infra/emdash-bot/.flue/raw.d.ts b/infra/emdash-bot/.flue/raw.d.ts index c22c0780e1..7fb3c06352 100644 --- a/infra/emdash-bot/.flue/raw.d.ts +++ b/infra/emdash-bot/.flue/raw.d.ts @@ -2,3 +2,10 @@ declare module "*?raw" { const content: string; export default content; } + +declare module "*/SKILL.md" { + import type { SkillReference } from "@flue/runtime"; + + const skill: SkillReference; + export default skill; +} diff --git a/infra/emdash-bot/.flue/skills/diagnose/SKILL.md b/infra/emdash-bot/.flue/skills/diagnose/SKILL.md new file mode 100644 index 0000000000..ab3f2ae265 --- /dev/null +++ b/infra/emdash-bot/.flue/skills/diagnose/SKILL.md @@ -0,0 +1,64 @@ +--- +name: diagnose +description: Trace from a reproduced symptom to the source code that causes it. Pin the specific file and approximate line, rate confidence in the cause and clarity of the fix independently, and always propose a concrete fix. +--- + +# Diagnose + +Reproduce handed you a symptom -- a failing test, a screenshot, a console error, a wrong HTTP response. Find the code that produces it and explain why, in enough detail that verify can decide whether it is a bug and fix can act if it is. + +You **read code only.** No edits, no test runs, no dev servers. The working tree is identical when you finish. + +## Environment + +This is pure inspection, so it is **entirely isolate work.** Use `read`, `ls`, and `exec` with `grep`/`rg`/`git grep`/`git log`/`git show` to walk from symptom to source. Do not attach a container -- you are not running anything. + +## Do not + +- No edits, no `git commit`, no `git push`. +- No GitHub writes. Read-only API GETs only. +- No network beyond the clone and the proxy-signed GitHub API. +- Touch no issue other than the one being investigated. + +## Procedure + +1. **Anchor on the repro transcript.** It already named a file, command, or URL -- start there. If reproduce was skipped, anchor on the file paths, error messages, or stack frames in the issue body. +2. **Walk from symptom to source.** + - Thrown exception with a stack trace: read each frame in order from the deepest _application_ frame (not framework internals). Confirm the call sequence matches what reproduce actually executed. + - Wrong return value: grep for the function that produced it, then trace its inputs back to where they enter the system (handler boundary, CLI entry, render call). + - Wrong HTML or DOM: identify the component or Astro page that renders it, then check what data it consumes and where that data comes from -- the bug is often in the data layer, not the render layer. + - Migration or schema bug: read the migration in question, the SchemaRegistry path that invoked it, and the surrounding migrations for ordering assumptions. +3. **Read the candidate code in full.** Do not skim. Read the whole function, the whole handler, the whole component -- bugs hide in adjacent branches. +4. **Check the recurring EmDash culprits first.** + - Missing `locale` filter on a content-table query (a known recurring class). + - SQL identifier interpolated unsafely instead of `sql.ref()` / `validateIdentifier()`. + - Off-by-one in pagination cursor encode/decode. + - Missing `await` on a promise whose result is ignored. + - `noUncheckedIndexedAccess` undefined-handling patched with `!` that is now wrong. + - Permission check missing or run on the wrong actor. + - Lingui `t` called at module scope. + - Physical Tailwind class (`ml-*`, `text-left`) where a logical one belongs. +5. **Pin the location.** The file and the smallest line range containing the bug. One line is ideal; a function-sized range is acceptable when the bug is structural. If you cannot get below file level, you do not have a diagnosis yet -- search more. +6. **Rate confidence in the root cause.** This axis is _only_ how sure you are you found the responsible code -- not how easy the fix is. + - **High** -- traced symptom to a specific file and line range, mechanism explainable end to end; another engineer would agree. + - **Medium** -- right area and a strong candidate, but the mechanism is unconfirmed (reproduce was skipped/failed, or a second plausible cause you cannot rule out by reading). + - **Low** -- multiple indistinguishable causes, or the right area but no specific defect visible. + Rate honestly both ways. Fix does not run at `low` but _does_ run at `medium` when the fix is clear -- do not reflexively rate down. A confidently located cause is `high` even when the fix involves choosing between options; that choice is the next field's job. +7. **Choose a fix approach (independent of confidence).** + - **mechanical** -- one obviously-correct change: a line or tight block, no judgement (a missing `await`, a wrong operator, a missing `locale` filter). + - **clear-best-option** -- bigger than a one-liner, or several shapes exist, but one is clearly right: backwards-compatible, matches existing patterns, confirmable by the repro test. Name it and say why it beats the alternatives. Sibling code in the same file is strong evidence of intent -- if one branch already does the right thing, mirroring it is `clear-best-option`, not a design decision. + - **needs-design-decision** -- choosing correctly needs a maintainer's judgement: a new public API or option, a shared component that does not exist yet, a behavioural-contract change, or a security/performance tradeoff. Do not guess; lay out the options. Do not retreat here just because more than one fix is conceivable -- reserve it for when the _right_ choice genuinely belongs to a maintainer. +8. **Write the proposed fix, always.** For `mechanical` / `clear-best-option`: the specific change -- which file, what to add/remove/change, and how the repro test proves it -- concrete enough that fix can implement it without re-deriving your reasoning. For `needs-design-decision`: the viable options, the tradeoff that separates them, and your recommendation if you have one. +9. **Write hypothesis notes for alternative causes.** What _other_ root causes did you consider, and how did you rule them in or out? Empty only when the cause is genuinely unambiguous. This is the most valuable part of a `medium` or `low` diagnosis for the maintainer. + +## Output + +Return: + +- Root cause: file path with approximate line (e.g. `packages/core/src/api/handlers/menus.ts:142`) plus prose on what is wrong and why it produces the symptom. +- Confidence in the cause: `high`, `medium`, or `low`. +- Fix approach: `mechanical`, `clear-best-option`, or `needs-design-decision`. +- Proposed fix: the concrete change, or the options a maintainer must choose between. Never empty. +- Hypothesis notes: alternative causes considered and what distinguishes them; empty only when unambiguous. + +Be specific and carry your evidence. "Probably in the menu code somewhere" is not a diagnosis. "`resolveContentUrl` in `packages/core/src/menus/index.ts:87` issues three queries per item and the third is the missing-locale fallback -- on a primary-locale request it is dead code but still runs" is. diff --git a/infra/emdash-bot/.flue/skills/fix/SKILL.md b/infra/emdash-bot/.flue/skills/fix/SKILL.md new file mode 100644 index 0000000000..c2081cbc0f --- /dev/null +++ b/infra/emdash-bot/.flue/skills/fix/SKILL.md @@ -0,0 +1,70 @@ +--- +name: fix +description: Implement diagnose's proposed fix when verify says bug, the cause is pinned, and a maintainer triggered a fix. Follow EmDash conventions, prove the repro test passes, run lint and typecheck, and leave a verified candidate for the preview-build loop. +--- + +# Fix + +You are here because a maintainer issued a **fix** directive, verify returned `bug`, diagnose pinned the cause with at least `medium` confidence, and diagnose rated the fix `mechanical` or `clear-best-option`. Diagnose handed you a **proposed fix** -- a concrete plan naming the file and the change. Implement that plan, prove it works, and leave the change verified. The hard reasoning is done; do not re-litigate the diagnosis unless reading the code convinces you it is wrong (then abandon -- see below). + +**What your output is, and is not.** You are not merging and not opening a PR. You commit and push your change to the issue's `bot/fix-` candidate branch as the spine instructs; the push triggers a **preview build** the workflow posts to the issue; the reporter is asked to confirm it fixes _their_ case. **Only after the reporter confirms** does a draft PR open, and a maintainer reviews before anything reaches `main`. So the bar is "a correct, conventions-respecting change that makes the repro test pass" -- not "a perfect, unimprovable patch." A clear, test-backed fix is worth shipping for verification even when it is more than a one-liner. Equally: do not gold-plate, do not expand scope, do not refactor beyond the diagnosed bug. + +## Environment + +- **Edit in the VFS** with the `edit` / `write` tools; read surrounding code with `read` in the isolate. +- **Run tests, lint, typecheck, and format in an attached container** -- none of the toolchain exists in the isolate. Attach once you are ready to verify, and do all `pnpm` work there. + +## Do not + +- No `git tag` and no PR creation. Push only the issue's `bot/fix-` branch, with `--force-with-lease`, exactly as the spine instructs -- the push capability rejects every other ref. The workflow owns the preview and the PR. +- No GitHub writes. Read-only API GETs only. +- No network beyond the clone, the proxy-signed GitHub API, and the npm registry. +- No `pnpm publish` / `npm publish`. +- No drive-by edits. Touch only the files the diagnosed bug and its test need. A problem in a nearby file is a human's -- scope discipline. +- Do not modify Lingui catalogs (`packages/admin/src/locales/*/messages.po`); the extract workflow handles them on merge. + +## Procedure + +1. **Re-read diagnose's root cause and proposed fix.** That is your target and your spec. The change should land in the file and approximate line diagnose named. If your work drifts to a different file, stop -- diagnose may be wrong, in which case abandon, do not wander. +2. **Establish a regression test where feasible.** Reproduce usually confirmed the bug without a test on disk. If the bug is unit- or integration-testable (a handler, a query, a pure function, an API route), write a `vitest` test now that fails for the reported reason, and confirm it fails in the container (`pnpm --filter test `) _before_ you touch the fix. A testable bug with no regression test is not fixed. If the bug only manifests in the browser (admin interaction, rendered output), do not write a browser test -- you cannot run one reliably here; verify through `agent-browser` instead and describe that manual verification so the maintainer can add a durable test when landing. +3. **Implement the proposed fix -- the smallest change that fully resolves the bug.** Follow EmDash conventions: + - Internal imports end `.js`; type-only imports use `import type`. + - State-changing routes start with `export const prerender = false;`. + - Never interpolate values into SQL: Kysely `sql` tagged template for values, `sql.ref()` for identifiers, `validateIdentifier()` before any `sql.raw()`. + - Handlers return `ApiResult`; errors use `apiError` / `handleError` with `SCREAMING_SNAKE_CASE` codes; never expose `error.message` to clients. + - Authorization via `requirePerm` / `requireOwnerPerm` from `#api/authorize.js`; permissions live in `packages/auth/src/rbac.ts` -- do not invent strings inline. + - Pagination returns `{ items, nextCursor? }` via `encodeCursor` / `decodeCursor`. + - Content-table queries filter by `locale`. + - Admin strings go through Lingui; logical Tailwind classes only. + - `import.meta.env.DEV`, never `process.env.NODE_ENV`. + - Migrations are forward-only and additive; register in `runner.ts` via `StaticMigrationProvider`. + - Prefer additive changes. A breaking change needs an explicit changeset -- do not introduce one for an automated fix without compelling justification. +4. **Run the repro test (container).** It must now pass. If not, your fix is wrong or incomplete -- investigate, adjust, or abandon. Never weaken the test to make it pass. +5. **Run the affected package's suite (container).** `pnpm --filter test`. Read the output. New failures in tests you did not write are regressions -- fix them or abandon the whole change. Do not push regressions through. +6. **Typecheck (container).** `pnpm typecheck` for packages, `pnpm typecheck:demos` if a demo was involved. No new errors. +7. **Lint (container).** `pnpm lint:quick`. If the count looks off, snapshot with `pnpm lint:json | jq '.diagnostics | length'` -- a clean baseline stays clean. +8. **Format (container).** `pnpm format` (oxfmt, tabs). Do not bypass it. Format only the files you touched -- a repo-wide format reformats already-committed files and blows scope. +9. **Add a changeset when a published package changed.** Create the file under `.changeset/` (patch bump for a bug fix unless diagnosis says otherwise). Write it as release notes for someone upgrading -- lead with a verb, describe the observable effect, reference the issue -- not as a commit message. Include it in your fix commit. + +## When to abandon + +Return not-fixed, with a clear reason, when: + +- The repro test does not actually fail before your change (diagnose or reproduce was wrong). +- Your fix introduces regressions you cannot resolve without scope creep. +- The fix turns out to need breaking-change-level design decisions a human should make. +- Lint, typecheck, or format produces errors you cannot resolve cleanly. + +A failed attempt is still useful -- the bot posts the diagnose and verify output and explains why the automated fix was abandoned. + +## Output + +Return: + +- Whether the fix succeeded. +- The conventional-commit message you used: `fix(): (#)`, scope matching the package or area (`fix(core/menus)`, `fix(admin/seo)`, `fix(migrations)`). +- The list of changed file paths, repo-root-relative. +- Whether the repro test currently passes against your change -- with the command and its output as evidence. +- Notes: design choices, rejected alternatives, edge cases, or (when not fixed) the specific reason you abandoned. + +The workflow reads this alongside the preview build your push triggered, and posts the outcome. It does not open a PR until the reporter confirms the preview fixes their case. diff --git a/infra/emdash-bot/.flue/skills/investigate/SKILL.md b/infra/emdash-bot/.flue/skills/investigate/SKILL.md new file mode 100644 index 0000000000..9884f21927 --- /dev/null +++ b/infra/emdash-bot/.flue/skills/investigate/SKILL.md @@ -0,0 +1,90 @@ +--- +name: investigate +description: Investigate a single EmDash issue end to end -- classify, choose a repro path, reproduce, diagnose, verify, and (only on an explicit maintainer fix directive) fix and confirm. Every verdict carries its evidence. +--- + +# Investigate an EmDash issue + +You investigate one issue on `emdash-cms/emdash`. You run inside an `@cloudflare/computer` Workspace attached to the Orchestrator DO for this issue. The EmDash repo is cloned into the Workspace filesystem via `git.clone` (shallow) at `/workspace/repo` -- that is your working root. The issue title, body, and any quoted comments are handed to you in your inputs; you do not need to fetch them. + +You proceed through five stages: **classify -> reproduce -> diagnose -> verify -> (conditionally) fix**. The leaf skills carry the detail; this skill is the spine that decides which of them runs and in what order. + +## The one rule that overrides everything: no confident noise + +Every stage produces a verdict, and **every verdict carries its evidence** -- the exact commands you ran and the output they produced. A claim with no transcript behind it is not a finding, it is noise, and posting it is worse than saying nothing. + +"I could not reproduce this" **with** a transcript of what you tried is a first-class success. "I could not reproduce this" with nothing behind it is a failure. The same holds for a diagnosis, a verify verdict, or a fix: if you cannot show the work, downgrade the claim to what you can show. + +## Execution environment + +Your Workspace tools are `read`, `write`, `edit`, `ls`, and `exec`, over a SQLite-backed virtual filesystem. + +- **`read` / `ls` / `edit` / `write`** operate on the VFS directly. Prefer them over shelling out to `cat`, `sed`, or `echo`. +- **`exec` runs in the isolate by default** (bash-in-isolate via just-bash). The isolate is fast and cheap and spins up instantly. Use it for the overwhelming majority of the work: `grep`/`rg`, `git log`/`show`/`diff`/`grep`, listing and slicing files, walking the tree -- anything that inspects the checkout without running the project's own toolchain. +- **The isolate cannot run the project.** There is no `node`, `pnpm`, `astro`, `vitest`, or browser there. When you need any of those, **attach a container** and run `exec` inside it. Container attach is the slow, heavyweight path -- it is where and only where you run `pnpm install`, `astro build`, the dev server, `vitest`, and `agent-browser`. +- **Dev servers background natively.** For an admin or public repro, start the demo with `astro dev --background` (`astro preview --background` since 7.2) -- it detaches, enables JSON logging, and returns once ready; check `astro dev status` / `.astro/dev.json`, tail `astro dev logs --follow`, stop with `astro dev stop`. No external process manager. The server persists for the lifetime of the attached container, so start it once and reuse it across steps. + +The discipline: **isolate-first, container on demand.** Do every read, grep, and git inspection in the isolate. Escalate to a container the moment -- and only the moment -- you need to install, build, run tests, or drive a browser. Each leaf skill states which path it needs; follow it. + +The design target is that fewer than one investigation step in ten needs a container. If you find yourself in a container for grep or file reads, you are doing it wrong -- drop back to the isolate. + +## GitHub access + +You are **read-only on GitHub.** The issue text is in your inputs. If you need more (a linked PR, a referenced file at a ref, the full comment thread), use read-only GitHub API GETs -- they are proxy-signed and scoped to this repo. You cannot comment, label, react, edit, close, or open anything via the API; every write 403s. The Orchestrator DO posts the single outcome comment from your reported result -- do not attempt mid-run comments. Touch no issue other than the one you are assigned. + +## Stage 1 -- Classify + +Read the issue body and any quoted comments in your inputs. + +1. **`kind`**: `bug`, `enhancement`, `documentation`, or `question`. Labels found on the issue are a hint, not ground truth -- a maintainer can mislabel and still trigger investigation. +2. **`area`**: `api`, `admin`, `public`, `migration`, `build`, or `other`. + - `api` -- REST handlers (`packages/core/src/api/`), the CLI (`packages/core/src/cli/`), the MCP server, anything exercised without a browser. + - `admin` -- the React SPA (`packages/admin`), anything under `/_emdash/admin/*`. + - `public` -- the rendered public site (Astro pages outside `/_emdash`), routing, SSR output, query patterns anonymous readers hit. + - `migration` -- migrations (`packages/core/src/database/migrations/`), schema registry, content tables. + - `build` -- bundling, tsdown, Vite, type generation, package exports, monorepo wiring. + - `other` -- infra, meta, anything that fits nothing above. + - A migration or build bug that only _surfaces_ through the admin UI is classified by its underlying area, not the surface. +3. **`requiresBrowser`**: true when `area` is `admin` or `public`; false otherwise. + +**If `kind` is not `bug`, stop here.** Return the classification with a one-line note on what kind of issue it is. Reproduce/diagnose/verify/fix do not run for enhancements, docs, or questions -- the DO posts a short acknowledgement, not a triage report. + +## Stage 2 -- Reproduce + +The expensive stages (reproduce onward) run only because a maintainer triggered this investigation. That trigger is the budget -- do the work properly, but do not wander. + +Dispatch on `area`: + +- `api`, `migration`, `build`, `other` -> **`repro-api`** (no browser; prefer a failing vitest test). +- `admin` -> **`repro-admin`** (container + agent-browser via the dev-bypass session). +- `public` -> **`repro-public`** (container + agent-browser against public routes). + +Each repro skill returns: whether it reproduced, the approach it used, a replayable transcript (commands + output, or the agent-browser step sequence + screenshots), and whether it is skipping (with the reason). Carry that forward unchanged. + +- If reproduce **skips** (environment genuinely cannot trigger the bug): do not run diagnose or fix. Run verify only if the issue body plus a static read of the source is enough to form an opinion; otherwise return the classification plus the skip reason. +- If reproduce **fails to reproduce** (tried, could not, not skipped): still run diagnose. The issue text alone is often enough to name the code path, and a grounded guess beats silence -- diagnose lowers its own confidence to match. + +## Stage 3 -- Diagnose + +Follow **`diagnose`**. Feed it the repro transcript. It returns a root cause (file + approximate line + prose), a confidence rating in that _cause_, a fix approach (`mechanical`, `clear-best-option`, or `needs-design-decision`) rating the _fix_, a concrete proposed fix, and hypothesis notes on alternative causes. Confidence and fix approach are independent axes -- a confidently located bug with one obvious backwards-compatible change is `high` + `clear-best-option`. + +## Stage 4 -- Verify + +Follow **`verify`**. It reads the diagnosed code, its comments, the docs, `AGENTS.md`, and the related tests, and decides `bug`, `intended-behavior`, or `unclear`. This is the gate that stops the bot from "fixing" behaviour that is working as designed. + +## Stage 5 -- Fix (conditional, maintainer-triggered) + +Run **`fix`** only when **all** hold: + +- The maintainer directive for this run is an explicit **fix** directive (not repro/diagnose-only). +- `verify.verdict === "bug"`. +- `diagnose.confidence !== "low"` (cause pinned to at least medium). +- `diagnose.fixApproach !== "needs-design-decision"` (fix is `mechanical` or `clear-best-option`). + +Any other combination: stop after verify. Post the diagnosis (proposed fix, or the options for a design decision) and the verify reasoning; a human takes it from there. + +**The fix loop does not open a PR.** Fix produces a verified change, committed and pushed to the issue's `bot/fix-` candidate branch -- the only ref the push capability can update. The push triggers a **preview build** the workflow posts to the issue, and the reporter is asked to confirm it resolves _their_ case. **Only after the reporter confirms** does a draft PR open (carrying the repro test, referencing the issue). Reporter denial or silence reaps the branch. Nothing you do here lands on `main`; a maintainer reviews the eventual PR. + +## Output + +Return one structured result combining the classification, the repro result, the diagnose result, the verify result, and the fix result if it ran. Omitted stages are explicitly absent, not filled with placeholders. Keep prose factual: if you guessed, say so; if you skipped a stage, say why in one sentence. Every non-trivial claim names the command or file that backs it. diff --git a/infra/emdash-bot/.flue/skills/investigate/instructions.md b/infra/emdash-bot/.flue/skills/investigate/instructions.md deleted file mode 100644 index 6670be70a6..0000000000 --- a/infra/emdash-bot/.flue/skills/investigate/instructions.md +++ /dev/null @@ -1,100 +0,0 @@ -# Investigate an EmDash issue - -You are emdashbot, running in a sandboxed Debian container with `bash`, `git`, `pnpm`, `node`, `agent-browser`, and `bgproc`. The EmDash repo (`emdash-cms/emdash`) is already cloned at `/workspace/repo`, which is your working directory. - -`git` to github.com works transparently. You have **no credentials in your env or filesystem** -- an outbound proxy outside the sandbox injects authentication for github.com / api.github.com / codeload.github.com. The proxy is the only network path that exists; everything else is denied. Don't waste turns probing the network: there's no way out except github + npm + nodejs.org. - -The proxy also signs api.github.com calls. **GitHub API access is read-only** (GET/HEAD only) and limited to the configured EmDash repository. POST/PATCH/PUT/DELETE to `api.github.com` always 403 — do not attempt comments, reactions, or other writes via the API. Only interact with the issue you were assigned. - -- `curl https://api.github.com/...` GET anything in the configured repo (read issues, PRs, files, blobs). -- Do **not** POST comments or reactions to the API. Your structured `report_result` summary is how outcomes reach the reporter; the orchestrator posts that as the issue comment. -- Writes outside the configured repository are denied (403). Don't try. - -Your final `summary` (in the structured result) is the primary thing the reporter sees -- write it for them, not for yourself. Do not try to post mid-run comments via the API; put genuine blockers and findings in `summary` when you call `report_result`. - -You run in **one of three modes** -- the orchestrator tells you which: - -- **`repro`**: a bug report. Reproduce the failure, diagnose it, fix it, verify the fix. -- **`implement`**: a feature or directed change. Build it. The `arg` in your inputs is the directive (read it carefully). -- **`revise`**: a follow-up to a previous PR. The existing branch `bot/fix-` is already checked out; the `arg` is the reviewer's feedback. Apply it. - -At the end, push to `bot/fix-` (or update the existing branch in `revise` mode) and call `report_result` with the structured result. The orchestrator opens the PR. - -## Method - -Read the issue body in your inputs. Decide whether it's actionable: - -- **Out of scope?** (a question, vendor bug, won't-fix design choice): set `skipped: true`, write a 1-2 sentence `summary` explaining why, return. -- **Intended behavior?** (the reporter misunderstood -- the code is correct): set `verdict: "intended-behavior"`, write a summary explaining the actual behavior with file:line references, return without changes. -- **Otherwise**: investigate. - -Working agreement: - -- **Read before writing.** Find the relevant files via `git grep` / `rg`. Read at least the immediate context (not just the line being changed). Trace call-sites. -- **AGENTS.md is in `/workspace/repo/AGENTS.md`** -- it's the canonical operating rules for this repo. Read it before making changes. Respect Lingui localization, RTL-safe Tailwind, the SQL-safety rules, the API envelope shape, and the changeset requirement for published packages. -- **Don't run `pnpm install` unless you've changed a `package.json` or `pnpm-workspace.yaml`**. The workflow has already run `pnpm install --frozen-lockfile` against the checked-out tree, so deps are ready when you start. -- **Don't bulk-format or lint.** Touch only the files relevant to this issue. The orchestrator will reject patches that change `.github/workflows/`, `pnpm-lock.yaml` (unless deps changed), or unrelated source files. -- **Tests are gold.** A bug without a reproducing test is not fixed. Before claiming `fixed: true`, write a failing test in the appropriate `tests/unit/` or `tests/integration/` directory, confirm it fails on the current code, then apply the fix and confirm it passes. - -## Repro mode - -1. Read the issue body and any comments quoted in `prContext`. -2. Find the relevant code paths via search. -3. Construct a reproduction: typically a vitest test that exercises the bad path. If the bug is admin-UI-only, use `agent-browser` with the dev-bypass endpoint (see AGENTS.md for the URL). If you genuinely cannot reproduce the bug, set `reproduced: false`, write a summary describing what you tried, and return without changes. -4. If you reproduced: write the fix, run the test, confirm pass. Set `reproduced: true`, `fixed: true`. - -## Implement mode - -1. Read the directive in `arg` and the issue body. -2. Plan the smallest change that delivers what was asked. No drive-by refactors. -3. Implement, add tests where they make sense, run them. -4. Set `fixed: true` on success. - -## Revise mode - -The branch `bot/fix-` is checked out. Your inputs include the reviewer's feedback in `arg` and the prior PR context in `priorReviewContext`. - -1. Read the feedback. -2. Apply changes. Keep the existing commit history; just amend or add commits on top. -3. Run tests. -4. Set `fixed: true` on success. - -## Returning - -Always call `report_result` exactly once with this schema: - -```json -{ - "skipped": false, - "reproduced": true, - "fixed": true, - "verdict": "bug", - "summary": "Two-sentence factual summary of what you found and what you did." -} -``` - -- `skipped`: true only for out-of-scope issues (you didn't write a fix). -- `reproduced`: true if you confirmed the bug exists. Only meaningful in `repro` mode. -- `fixed`: true if you wrote a fix you believe resolves the issue AND the new test passes. -- `verdict`: `"bug"` (real bug, fixed or otherwise), `"intended-behavior"` (the code is correct, the report is wrong), or `"unclear"` (you couldn't determine). -- `summary`: **the reporter will see this verbatim as your comment on the issue.** Write directly to them, like a maintainer would. Concrete, no marketing language. Mention file paths (`packages/core/src/...`). If you wrote a fix, name the test file you added. If you couldn't reproduce, say what you tried. If you think it's intended behaviour, point at the code that documents the intent. 2-4 sentences usually; longer if the change is non-obvious. - -## Commit and push - -Identity is already configured (`emdashbot[bot]`). For `repro` / `implement`: - -```bash -cd /workspace/repo -git checkout -B bot/fix- -git add -git commit -m "" -git push -u origin HEAD --force-with-lease -``` - -For `revise` you're already on `bot/fix-`; just `git add`, `git commit`, `git push`. - -**Do not touch the remote URL.** The clone is configured with `https://github.com/emdash-cms/emdash.git` and the outbound proxy injects auth invisibly. Don't run `git remote set-url`, don't add `https://x-access-token:...` to URLs, don't configure credential helpers. Just `git push`. If push prompts for a username, that means something else is wrong (proxy failure, network drop) -- don't try to work around it; report `fixed: false` with the error in `summary`. - -If you have no changes to commit, do not push. Report `fixed: false`. - -If `git push` fails because someone else pushed to the branch, do NOT use a non-lease force. Report `fixed: false` with the conflict reason in `summary` and let a human reconcile. diff --git a/infra/emdash-bot/.flue/skills/repro-admin/SKILL.md b/infra/emdash-bot/.flue/skills/repro-admin/SKILL.md new file mode 100644 index 0000000000..36371ffb9a --- /dev/null +++ b/infra/emdash-bot/.flue/skills/repro-admin/SKILL.md @@ -0,0 +1,54 @@ +--- +name: repro-admin +description: Reproduce an EmDash admin UI bug. Attach a container, start the demo dev server, drive the admin with agent-browser using the dev-bypass session, and capture the reproduction as screenshots plus a replayable transcript. +--- + +# Reproduce: Admin UI + +The bug is in the React admin under `/_emdash/admin/*`. You need a running demo, an authenticated session, and a way to drive the UI through the reporter's steps. Reproduce and confirm entirely through `agent-browser`: the durable artifacts are your screenshots plus a precise, replayable transcript. **Do not write Playwright or any other browser test** -- you cannot run one reliably here, so an unrun test is unverified guesswork. The regression test belongs to whoever lands the fix. + +## Environment + +Everything in this skill runs in an **attached container** -- the dev server, `agent-browser`, and any CLI seeding all need node and a browser, none of which exist in the isolate. Do your issue-reading and any source grepping in the isolate first, then attach the container for the reproduction itself. + +## Do not + +- No `git commit`, `git push`, or branch creation. +- No GitHub writes. Read-only API GETs only. +- No network beyond `localhost` (the demo) and the proxy-signed GitHub API. +- Touch no issue other than the one being investigated. +- Do not modify Lingui catalogs (`packages/admin/src/locales/*/messages.po`) -- a workflow regenerates them on merge; touching them here is churn. + +## Procedure + +1. **Re-read the issue (isolate).** Note the exact steps, the page, the browser, and any screenshots or stack traces. If the steps reference a collection or content item, decide whether the default demo seed covers it or whether you must create content first. +2. **Pick a demo.** `demos/simple` is the default and covers most admin reproductions. Use a more specific demo only when the issue names one. +3. **Attach a container and start the demo dev server.** Astro 7 backgrounds dev servers natively -- from the demo directory run `astro dev --background` (e.g. `pnpm --filter ./demos/simple exec astro dev --background`). It detaches, enables JSON logging, and returns once the server is up, so you do not poll or improvise process management. Read the URL/port/PID and readiness from the `.astro/dev.json` lock file or `astro dev status`; Astro serves the demo on `localhost:4321`. Tail output with `astro dev logs --follow`. If the server never comes up, capture `astro dev logs` and treat that as a **setup failure, not a reproduction**. Stop it with `astro dev stop` when done (or just leave it -- it dies with the container). +4. **Get a session.** Point agent-browser at the dev-bypass endpoint: `agent-browser open "http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin"`. This runs migrations, creates the dev admin user (`dev@emdash.local`), sets a session cookie, and lands you on the admin home. The endpoint is gated to `import.meta.env.DEV`, so it exists only locally -- never against a deployed environment. +5. **Drive the UI.** `agent-browser snapshot -i -c` gives an accessibility tree with `@e` refs. Interact with `click @e`, `fill @e "text"`, `select @e "option"`. Refs are stable only within one snapshot -- re-snapshot after every navigation or DOM change. +6. **Screenshot at meaningful steps.** Save to `.bot-artifacts/step-.png`: one on landing, one at the point the reporter says the bug appears, one of the broken state. Use `--full` only when the bug is below the fold. Keep file sizes reasonable. +7. **Watch for JS errors.** After each interaction run `agent-browser console` and `agent-browser errors`. React key warnings and unmounted-setState noise are almost never the bug; runtime exceptions usually are. +8. **Confirm the failure mode matches.** A different broken state is not a reproduction. If you can only reach an adjacent broken state, say so. Write the exact replayable sequence -- URL, refs/selectors, inputs, observed broken state -- so a maintainer can follow it without you. + +## When to skip + +Mark skipped, with the reason, when: + +- The bug needs a browser engine agent-browser's headless Chromium cannot drive faithfully (rare; usually a Safari-specific layout quirk). +- The bug needs OS-level interaction beyond a headless browser -- native file pickers in non-trivial drag-drop, OS clipboard internals, IME flows, hardware key combos. +- The bug only reproduces with a real user's extensions or profile (a password manager or autofill injecting into inputs). A clean headless browser has none. Say so -- this is a real bug class the bot cannot trigger. +- The bug needs real Cloudflare Access in front of the admin. Dev-bypass skips Access; "Access redirects me incorrectly" is not locally reproducible. +- The repro depends on production data, third-party OAuth, or a hosted environment. +- The demo will not boot for an unrelated reason -- the failure is in setup, not the admin code. + +## Output + +Return: + +- Whether you reproduced the bug. +- Whether you skipped, and the reason if so. +- The approach: `agent-browser-only` or `none`. +- Notes: a short paragraph naming the demo, the URL path where the symptom appeared, the interaction sequence in plain prose, and any console or runtime errors. +- A list of screenshots, each with its `.bot-artifacts/` filename and a one-line description. + +A "could not reproduce" result backed by the transcript and screenshots of what you tried is a valid, useful outcome -- return it as one. diff --git a/infra/emdash-bot/.flue/skills/repro-api/SKILL.md b/infra/emdash-bot/.flue/skills/repro-api/SKILL.md new file mode 100644 index 0000000000..4ac597b0a4 --- /dev/null +++ b/infra/emdash-bot/.flue/skills/repro-api/SKILL.md @@ -0,0 +1,57 @@ +--- +name: repro-api +description: Reproduce an EmDash bug below the browser layer -- REST handlers, CLI, MCP, migrations, schema registry, or build tooling. No browser. Prefer a failing vitest test in the affected package, run in an attached container. +--- + +# Reproduce: API / CLI / Migration / Build + +The bug does not need a browser. It lives in a handler, the CLI, the MCP server, a migration, the schema registry, or the build pipeline. Your goal is a deterministic reproduction you can put in the comment as evidence -- ideally a failing vitest test that becomes the regression fixture once fixed. + +## Environment + +- **Read and search in the isolate.** Use `read`, `ls`, and `exec` with `grep`/`rg`/`git grep` to find the package, read the handler in full, and trace call sites. This is most of the work and none of it needs a container. +- **Attach a container only to run the project.** `pnpm install`, `pnpm build`, and `vitest` do not exist in the isolate. When you are ready to actually execute a reproduction, attach a container and run those there. + +## Do not + +- No `git commit`, `git push`, or branch creation. This stage never writes to the remote. +- No GitHub writes (no comments, labels, reactions). Read-only API GETs only. +- No network beyond the repo clone, the proxy-signed GitHub API, and the npm registry. +- No `pnpm publish` / `npm publish`. +- Touch no issue other than the one being investigated. + +## Procedure + +1. **Anchor on the issue's exact words.** In the isolate, pull the commands, file paths, package names, and stack traces out of the issue body verbatim. Your reproduction matches what the reporter wrote, not a paraphrase. If the body links a repo or gist, fetch it read-only before choosing an approach. +2. **Find the package (isolate).** Use `area` plus file paths in the body. CLI -> `packages/core/src/cli/`. REST handlers -> `packages/core/src/api/handlers/`. Migrations -> `packages/core/src/database/migrations/`. MCP -> `packages/core/src/mcp/`. Build -> `packages/*/tsdown.config.ts` or root `pnpm-workspace.yaml`. If several packages are plausible, `grep` before guessing. Read the candidate code fully in the isolate before you spend a container on it. +3. **Attach a container.** Everything from here needs one. +4. **Install only if needed.** The clone may already carry `node_modules` from the base image / R2 template artifact. If it does, skip the install. Run `pnpm install --frozen-lockfile --prefer-offline` only when `node_modules` is missing or you changed a manifest -- it is the slowest thing you can do. +5. **Build only what you must.** Most reproductions target source directly through vitest. Run `pnpm --filter build` only when the bug is in compiled output or cross-package type generation. +6. **Choose an approach, in order of preference:** + - **Failing vitest test** in the package's `tests/` tree. Use `setupTestDatabase()` / `setupForDialect()` from `tests/utils/test-db.ts` for anything touching the database; use the dialect wrapper (`describeEachDialect`) when the bug could be dialect-specific. Mirror source structure (`.../src/api/handlers/foo.ts` -> `.../tests/integration/api/handlers/foo.test.ts`). Name it for the issue: `it("reproduces #: ", ...)`. Run with `pnpm --filter test ` and confirm it fails **for the reason reported**, not an unrelated setup error. + - **Repro script** under `/tmp/repro-/` when a test would need too much scaffolding (needs a built binary, needs to spawn children in a specific order). One file when possible; capture stdout, stderr, exit code. + - **`pnpm exec emdash ...`** when the bug is a single CLI invocation whose failure is obvious from the output. +7. **Capture evidence.** For every attempt record the exact command, the meaningful slice of stdout/stderr (trim -- do not dump thousands of lines), and the exit code. This transcript is the deliverable. +8. **Confirm the failure mode matches.** A crash for a different reason is not a reproduction. If you can only trigger an adjacent failure, say so and lower confidence. + +## When to skip + +Mark skipped, with the reason, when the reproduction genuinely cannot happen here. Do not burn container time fighting these: + +- Needs a WordPress export, customer dataset, or other artifact the reporter did not attach. +- Only manifests on a deployed Cloudflare Worker -- cold starts, eventual consistency, transient D1 errors, isolate eviction. A local run does not reproduce these faithfully. +- Needs Postgres at production scale (table sizes, pool exhaustion, planner choices). A handful of rows will not surface the same plan. +- Needs real Cloudflare Access, R2 credentials, AI Gateway routing, or other bindings the Workspace does not have. +- Timing-dependent heisenbug not reliably reproducible across runs. Note the symptom, leave it for a human. + +## Output + +Return: + +- Whether you reproduced the bug. +- Whether you skipped, and the reason if so. +- The approach: `failing-test`, `repro-script`, `pnpm-command`, or `none`. +- Notes: the exact command(s), the failure output, and anything diagnose will need. Include the test file path if you wrote one. +- An empty screenshots list -- this skill produces none. + +If you wrote a failing test, leave it in place; do not stage or commit it. A "could not reproduce" result with the full transcript of what you tried is a valid, useful outcome -- return it as one, not as silence. diff --git a/infra/emdash-bot/.flue/skills/repro-public/SKILL.md b/infra/emdash-bot/.flue/skills/repro-public/SKILL.md new file mode 100644 index 0000000000..639d15fc9c --- /dev/null +++ b/infra/emdash-bot/.flue/skills/repro-public/SKILL.md @@ -0,0 +1,52 @@ +--- +name: repro-public +description: Reproduce a bug in the public-facing rendered site (not the admin). Attach a container, start the demo dev server, drive public routes with agent-browser, and capture the reproduction as screenshots plus a replayable transcript. +--- + +# Reproduce: Public Site + +The bug is in the rendered public site -- Astro pages outside `/_emdash`, the SSR output a visitor sees, public routing, sitemap, RSS, image rendering, or query patterns anonymous readers hit. No admin session needed. Reproduce and confirm entirely through `agent-browser`: the durable artifacts are your screenshots, a captured DOM slice, and a precise, replayable transcript. **Do not write Playwright or any other browser test** -- you cannot run one reliably here. The regression test belongs to whoever lands the fix. + +## Environment + +The dev server, `agent-browser`, and any CLI seeding run in an **attached container** (node + browser; neither exists in the isolate). Read the issue and grep the source in the isolate first, then attach the container for the reproduction. + +## Do not + +- No `git commit`, `git push`, or branch creation. +- No GitHub writes. Read-only API GETs only. +- No network beyond `localhost` (the demo) and the proxy-signed GitHub API. +- Touch no issue other than the one being investigated. + +## Procedure + +1. **Re-read the issue (isolate).** Note the exact URL or route pattern, expected-vs-actual output, and any headers or query strings that mattered. Public-site bugs often hinge on the locale, the requested format (HTML vs RSS), or specific content rows -- be precise. +2. **Pick a demo.** `demos/simple` is the default. For a locale-specific bug, pick a demo with multiple locales seeded; for a collection-specific bug, one that already has that collection. +3. **Seed content only if necessary.** If the repro needs a content item the seed lacks, create it with the CLI in the container: `pnpm exec emdash content create --data '...'` (see the `emdash-cli` skill for exact flags). Prefer ephemeral CLI-created content over editing seed files -- it disappears with the Workspace. +4. **Attach a container and start the demo dev server.** Astro 7 backgrounds dev servers natively -- from the demo directory run `astro dev --background` (e.g. `pnpm --filter ./demos/simple exec astro dev --background`). It detaches, enables JSON logging, and returns once the server is up, so you do not poll or improvise process management. Read the URL/port/PID and readiness from the `.astro/dev.json` lock file or `astro dev status`; Astro serves on `localhost:4321`. Tail output with `astro dev logs --follow`. If the server never comes up, capture `astro dev logs` and treat it as a setup failure. Stop it with `astro dev stop` when done (or leave it -- it dies with the container). +5. **Open the affected route.** `agent-browser open "http://localhost:4321/"` with the exact path from the issue. Include any query string or `Accept` header the issue calls out. +6. **Inspect the rendered output.** `agent-browser snapshot -i -c` for the accessibility tree; `agent-browser get text @e` to extract a region. For RSS or other non-HTML output, fetch it through the browser's network panel rather than `curl` -- the browser follows the demo's Astro routing the way a visitor does. +7. **Check for runtime errors.** `agent-browser console` for hydration warnings, missing data, or 404 sub-requests; `agent-browser errors` for exceptions thrown during render or hydration. +8. **Screenshot at meaningful states.** Save to `.bot-artifacts/step-.png`: one of the page as loaded, one of the broken element if visible. +9. **Confirm the failure mode matches.** Public-site bugs are easy to misidentify -- rendering differences can come from missing seed data, a stale build artifact, or an unrelated route. If you cannot produce exactly the reported symptom, say so. Write the exact replayable steps (URL, any query string or `Accept` header, observed-vs-expected) so a maintainer can follow without you. + +## When to skip + +Mark skipped, with the reason, when: + +- The bug needs a specific crawler user-agent, OG-card validator, or third-party fetcher you cannot impersonate from `localhost`. +- The bug needs production-scale content (pagination edge cases, sitemap chunking) the demo cannot realistically produce in run time. +- The bug only manifests on a deployed Worker -- CF edge cache headers, geographic routing, image transformation through the production R2 binding. +- The bug needs a specific source dataset (e.g. a WordPress import) the reporter did not attach. + +## Output + +Return: + +- Whether you reproduced the bug. +- Whether you skipped, and the reason if so. +- The approach: `agent-browser-only` or `none`. +- Notes: the demo used, the exact URL, the interaction sequence in plain prose, and any console or runtime errors. +- A list of screenshots, each with its `.bot-artifacts/` filename and a one-line description. + +A "could not reproduce" result backed by the transcript and screenshots is a valid, useful outcome -- return it as one. diff --git a/infra/emdash-bot/.flue/skills/verify/SKILL.md b/infra/emdash-bot/.flue/skills/verify/SKILL.md new file mode 100644 index 0000000000..c5a94df2af --- /dev/null +++ b/infra/emdash-bot/.flue/skills/verify/SKILL.md @@ -0,0 +1,43 @@ +--- +name: verify +description: Decide whether the diagnosed behaviour is actually a bug or the code doing what it was designed to do. This is the gate that guards the fix stage. +--- + +# Verify + +Diagnose found code that explains the symptom. That does not make the code wrong. Plenty of EmDash issues describe behaviour that is intentional but under-documented, surprising at first glance, or a misuse of the API. Tell the difference -- fix runs only when you say `bug`. + +You read code, comments, docs, tests, and `AGENTS.md`. You **modify nothing.** No edits, no test runs, no dev servers. + +## Environment + +Pure inspection -- **entirely isolate work.** Use `read`, `ls`, and `exec` grep/git to cross-reference code, docs, and tests. Do not attach a container. + +## Do not + +- No edits, no `git commit`, no `git push`. +- No GitHub writes. Read-only API GETs only. +- No network beyond the clone and the proxy-signed GitHub API. +- Touch no issue other than the one being investigated. + +## Procedure + +1. **Re-read the diagnose output.** The file, the line range, the prose. Hold it in mind as you cross-reference. +2. **Read the surrounding code, not just the line.** Comments immediately above and below; the function's docstring/JSDoc; its name and signature (often documents intent); adjacent branches and other call sites. +3. **Cross-reference documentation.** `AGENTS.md` and `CONTRIBUTING.md` for repo-wide rules (SQL safety, locale filtering, RBAC, request caching, query-count budget); `docs/` for user-facing behaviour that may be intentional; the package README or top-level docstring. +4. **Cross-reference tests.** An existing test asserting the current behaviour means it is intentional -- unless the test itself is wrong. Open it, read what it asserts and why. A test named for the diagnosed function is the strongest intent signal the repo has. +5. **Decide -- three verdicts only:** + - **bug** -- behaviour matches the code, the code does _not_ match documented or clearly implied intent, and the reporter's expectation is reasonable. (Missing `locale` filter; off-by-one pagination; a 500 where a 404 belongs; a permission check admitting the wrong actor.) + - **intended-behavior** -- behaviour matches the code, and the code matches documented intent. (`{ items, nextCursor }` not a bare array; the `X-EmDash-Request` CSRF header requirement; slugs unique per-locale not globally per migration 019; a maintainer-only endpoint returning 403 to authors.) + - **unclear** -- docs are silent and intent cannot be inferred. Maybe a bug, maybe not; the maintainer decides. +6. **Resist two failure modes.** Do not call `intended-behavior` just because a test exists -- a test asserting wrong behaviour is part of the bug. Do not call `bug` just because the reporter is upset -- frustration is not a verdict. +7. **Explain, with citations.** One or two short paragraphs per verdict, citing the specific comment, doc section, or test by path. For `intended-behavior`, state the documented intent explicitly so the bot's comment can point the reporter at it ("I think this is by design -- see `` / `` -- happy to revisit if you disagree"). For `unclear`, list what you would need to know to decide. + +## Output + +Return: + +- Verdict: `bug`, `intended-behavior`, or `unclear`. +- Reasoning: prose supporting the verdict, with paths to the comments, docs, or tests you relied on. A verdict without a citation is confident noise -- cite or downgrade. + +The workflow uses your verdict as a gate. `bug` triggers fix only when diagnose also pinned the cause (confidence not `low`), rated the fix `mechanical` or `clear-best-option`, _and_ the maintainer directive is a fix directive. A `bug` needing a design decision, an `unclear`, or `intended-behavior` all stop here and produce a comment-only outcome. diff --git a/infra/emdash-bot/package.json b/infra/emdash-bot/package.json index d3c2446426..60eb65574c 100644 --- a/infra/emdash-bot/package.json +++ b/infra/emdash-bot/package.json @@ -27,6 +27,7 @@ "wrangler": "catalog:" }, "dependencies": { + "@cloudflare/computer": "0.1.1", "@cloudflare/sandbox": "^0.12.1", "@flue/runtime": "2.0.3", "agents": "^0.20.1", diff --git a/infra/emdash-bot/tests/integration/_entry.ts b/infra/emdash-bot/tests/integration/_entry.ts index 3fe663d240..a8c0a90805 100644 --- a/infra/emdash-bot/tests/integration/_entry.ts +++ b/infra/emdash-bot/tests/integration/_entry.ts @@ -14,7 +14,7 @@ import { Hono } from "hono"; import { registerCoreRoutes } from "../../.flue/routes.js"; -export { Sandbox, ContainerProxy } from "../../.flue/cloudflare.js"; +export { Sandbox, ContainerProxy, WorkspaceDO } from "../../.flue/cloudflare.js"; export { OrchestratorDO } from "../../.flue/lib/orchestrator.js"; const app = registerCoreRoutes(new Hono<{ Bindings: Env }>()); diff --git a/infra/emdash-bot/tests/unit/exec-env.test.ts b/infra/emdash-bot/tests/unit/exec-env.test.ts new file mode 100644 index 0000000000..c5c342a65a --- /dev/null +++ b/infra/emdash-bot/tests/unit/exec-env.test.ts @@ -0,0 +1,480 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + type ContainerBackend, + ExecEnv, + type IsolateBackend, + ISOLATE_SHELL_BACKEND, +} from "../../.flue/lib/exec-env.js"; + +interface RecordedExec { + source: string; + options: { backend?: string; cwd?: string; encoding: "utf8"; timeoutMs?: number }; +} + +const GIT_STATUS = "git status --porcelain -z --untracked-files=all"; + +function fakeIsolate( + overrides: Partial = {}, + sharedFiles?: Map, +): { + isolate: IsolateBackend; + execs: RecordedExec[]; + files: Map; + setExecResult: (result: { exitCode: number; stdout: string; stderr: string }) => void; + hangExec: () => void; +} { + const execs: RecordedExec[] = []; + const files = sharedFiles ?? new Map(); + let execResult = { exitCode: 0, stdout: "", stderr: "" }; + let hang = false; + const isolate: IsolateBackend = { + fs: { + readFile: async (path) => { + const value = files.get(path); + if (value === undefined) throw new Error(`no such file ${path}`); + return value; + }, + writeFile: async (path, content) => { + files.set(path, content); + }, + mkdir: async () => {}, + readdir: async () => [{ name: "a.ts", isDirectory: false }], + rm: async () => {}, + grep: async () => [{ path: "/repo/a.ts", line: 3, text: "TODO" }], + ...overrides, + }, + runtime: { + exec: async (source, options) => { + execs.push({ source, options }); + if (hang) return { result: () => new Promise(() => {}) }; + return { result: async () => execResult, [Symbol.dispose]: () => {} }; + }, + }, + }; + return { + isolate, + execs, + files, + setExecResult: (result) => { + execResult = result; + }, + hangExec: () => { + hang = true; + }, + }; +} + +function fakeContainer(): { + container: ContainerBackend; + execs: string[]; + writes: Array<{ path: string; content: string }>; +} { + const execs: string[] = []; + const writes: Array<{ path: string; content: string }> = []; + const container: ContainerBackend = { + exec: async (command) => { + execs.push(command); + return { exitCode: 0, stdout: "container-ran", stderr: "" }; + }, + writeFile: async (path, content) => { + writes.push({ path, content }); + }, + readFileBytes: async () => new Uint8Array([1, 2, 3]), + }; + return { container, execs, writes }; +} + +const deadlines = { defaultTimeoutMs: 10_000, execGraceMs: 500 }; + +describe("ExecEnv exec routing", () => { + test("isolate exec runs on the worker-shell backend and normalizes the handle result", async () => { + const iso = fakeIsolate(); + iso.setExecResult({ exitCode: 2, stdout: "hits", stderr: "warn" }); + const attach = vi.fn(async () => fakeContainer().container); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: attach, + deadlines, + repoDir: "/repo", + }); + + const result = await env.exec("grep -r TODO", { target: "isolate", cwd: "/repo" }); + + expect(result).toEqual({ exitCode: 2, stdout: "hits", stderr: "warn" }); + expect(iso.execs).toHaveLength(1); + expect(iso.execs[0]?.options.backend).toBe(ISOLATE_SHELL_BACKEND); + expect(iso.execs[0]?.options.encoding).toBe("utf8"); + expect(iso.execs[0]?.options.cwd).toBe("/repo"); + expect(attach).not.toHaveBeenCalled(); + }); + + test("container exec runs the command on the container; the isolate only runs the sync probe", async () => { + const iso = fakeIsolate(); + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + const result = await env.exec("pnpm install", { target: "container" }); + + expect(result.stdout).toBe("container-ran"); + expect(con.execs).toEqual(["pnpm install"]); + expect(iso.execs.map((e) => e.source)).toEqual([GIT_STATUS]); + }); +}); + +describe("ExecEnv deadlines", () => { + test("a hung isolate exec rejects with the labelled deadline error", async () => { + vi.useFakeTimers(); + try { + const iso = fakeIsolate(); + iso.hangExec(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines: { defaultTimeoutMs: 50, execGraceMs: 5 }, + repoDir: "/repo", + }); + const pending = env.exec("sleep 999", { target: "isolate", timeoutMs: 20 }); + const assertion = expect(pending).rejects.toThrow("isolate exec timed out after 25ms"); + await vi.advanceTimersByTimeAsync(30); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + test("container exec adds the grace margin to its own timeout", async () => { + vi.useFakeTimers(); + try { + const iso = fakeIsolate(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => ({ + exec: () => new Promise(() => {}), + writeFile: async () => {}, + readFileBytes: async () => new Uint8Array(), + }), + deadlines: { defaultTimeoutMs: 1_000, execGraceMs: 5 }, + repoDir: "/repo", + }); + const pending = env.exec("vitest", { target: "container", timeoutMs: 10 }); + const assertion = expect(pending).rejects.toThrow("container exec timed out after 15ms"); + await vi.advanceTimersByTimeAsync(20); + await assertion; + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("ExecEnv container lifecycle", () => { + test("the container is attached lazily and reused across execs", async () => { + const iso = fakeIsolate(); + const con = fakeContainer(); + const attach = vi.fn(async () => con.container); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: attach, + deadlines, + repoDir: "/repo", + }); + + expect(attach).not.toHaveBeenCalled(); + await env.exec("pnpm install", { target: "container" }); + await env.exec("pnpm test", { target: "container" }); + + expect(attach).toHaveBeenCalledTimes(1); + expect(con.execs).toEqual(["pnpm install", "pnpm test"]); + }); +}); + +describe("ExecEnv VFS->container materialization", () => { + test("materializes the current VFS content of paths git reports, not a memory snapshot", async () => { + const iso = fakeIsolate(); + iso.files.set("/repo/src/x.ts", "v1"); + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + await env.edit("/repo/src/x.ts", "v1", "v2"); + iso.setExecResult({ exitCode: 0, stdout: " M src/x.ts\0", stderr: "" }); + + await env.exec("pnpm test", { target: "container" }); + + expect(con.writes).toEqual([{ path: "/repo/src/x.ts", content: "v2" }]); + }); + + test("an edit in one instance is materialized when another attaches over the same VFS", async () => { + const files = new Map([["/repo/src/x.ts", "old"]]); + const con = fakeContainer(); + const envA = new ExecEnv({ + isolate: fakeIsolate({}, files).isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + await envA.edit("/repo/src/x.ts", "old", "new"); + + const isoB = fakeIsolate({}, files); + isoB.setExecResult({ exitCode: 0, stdout: " M src/x.ts\0", stderr: "" }); + const envB = new ExecEnv({ + isolate: isoB.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + await envB.exec("pnpm test", { target: "container" }); + + expect(con.writes).toEqual([{ path: "/repo/src/x.ts", content: "new" }]); + }); + + test("an edit after attach lands on a fresh instance's re-attach, past the reset", async () => { + const files = new Map([["/repo/src/y.ts", "base"]]); + const con = fakeContainer(); + const isoA = fakeIsolate({}, files); + const envA = new ExecEnv({ + isolate: isoA.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + await envA.exec("pnpm install", { target: "container" }); + await envA.writeFile("/repo/src/y.ts", "fixed"); + expect(con.writes).toHaveLength(0); + + const isoB = fakeIsolate({}, files); + isoB.setExecResult({ exitCode: 0, stdout: " M src/y.ts\0", stderr: "" }); + const attachB = vi.fn(async () => con.container); + const envB = new ExecEnv({ + isolate: isoB.isolate, + attachContainer: attachB, + deadlines, + repoDir: "/repo", + }); + + await envB.exec("pnpm test", { target: "container" }); + + expect(attachB).toHaveBeenCalledTimes(1); + expect(con.writes).toEqual([{ path: "/repo/src/y.ts", content: "fixed" }]); + }); + + test("a git-reported deletion is removed from the container", async () => { + const iso = fakeIsolate(); + iso.setExecResult({ exitCode: 0, stdout: " D src/gone.ts\0", stderr: "" }); + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + await env.exec("pnpm test", { target: "container" }); + + expect(con.execs).toEqual(["rm -f -- '/repo/src/gone.ts'", "pnpm test"]); + expect(con.writes).toHaveLength(0); + }); + + test("a rename deletes the old path and materializes the new (-z new-then-old order)", async () => { + const iso = fakeIsolate(); + iso.files.set("/repo/src/new.ts", "moved"); + iso.setExecResult({ exitCode: 0, stdout: "R src/new.ts\0src/old.ts\0", stderr: "" }); + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + await env.exec("pnpm test", { target: "container" }); + + expect(con.execs).toEqual(["rm -f -- '/repo/src/old.ts'", "pnpm test"]); + expect(con.writes).toEqual([{ path: "/repo/src/new.ts", content: "moved" }]); + }); + + test("a non-ASCII path is materialized verbatim (-z carries it unescaped)", async () => { + const iso = fakeIsolate(); + iso.files.set("/repo/café.ts", "☕"); + iso.setExecResult({ exitCode: 0, stdout: " M café.ts\0", stderr: "" }); + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + await env.exec("pnpm test", { target: "container" }); + + expect(con.writes).toEqual([{ path: "/repo/café.ts", content: "☕" }]); + }); + + test("edit throws when the target is absent or ambiguous", async () => { + const iso = fakeIsolate(); + iso.files.set("/repo/dup.ts", "x x"); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines, + repoDir: "/repo", + }); + + await expect(env.edit("/repo/dup.ts", "y", "z")).rejects.toThrow("not found"); + await expect(env.edit("/repo/dup.ts", "x", "z")).rejects.toThrow("not unique"); + }); +}); + +describe("ExecEnv artifact egress", () => { + test("reads a bare artifact name from under .bot-artifacts", async () => { + const con = fakeContainer(); + const env = new ExecEnv({ + isolate: fakeIsolate().isolate, + attachContainer: async () => con.container, + deadlines, + repoDir: "/repo", + }); + + const bytes = await env.readArtifact("step-1.png"); + + expect([...bytes]).toEqual([1, 2, 3]); + expect(con.execs[0]).toContain("/repo/.bot-artifacts/step-1.png"); + }); + + test("rejects any name that could escape the artifacts directory", async () => { + const attach = vi.fn(async () => fakeContainer().container); + const env = new ExecEnv({ + isolate: fakeIsolate().isolate, + attachContainer: attach, + deadlines, + repoDir: "/repo", + }); + + for (const bad of ["../secrets", "a/b.png", "/etc/passwd", "..", ".", "", "a\\b"]) { + await expect(env.readArtifact(bad)).rejects.toThrow("invalid artifact name"); + } + expect(attach).not.toHaveBeenCalled(); + }); + + test("refuses a symlinked artifact", async () => { + const container: ContainerBackend = { + exec: async () => ({ exitCode: 1, stdout: "", stderr: "" }), + writeFile: async () => {}, + readFileBytes: async () => new Uint8Array([9]), + }; + const env = new ExecEnv({ + isolate: fakeIsolate().isolate, + attachContainer: async () => container, + deadlines, + repoDir: "/repo", + }); + + await expect(env.readArtifact("evil.png")).rejects.toThrow("not a regular file"); + }); +}); + +describe("ExecEnv clone", () => { + const emptyVfs = { + readdir: async (path: string): Promise> => { + throw new Error(`no such directory ${path}`); + }, + }; + + test("cloneRepo runs a shallow isolate git clone of the public repo", async () => { + const iso = fakeIsolate(emptyVfs); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines, + repoDir: "/repo", + }); + + await env.cloneRepo({ + url: "https://github.com/emdash-cms/emdash.git", + dir: "/workspace/repo", + ref: "main", + depth: 50, + }); + + expect(iso.execs).toHaveLength(1); + expect(iso.execs[0]?.source).toBe( + "git clone --depth 50 --branch main 'https://github.com/emdash-cms/emdash.git' '/workspace/repo'", + ); + expect(iso.execs[0]?.options.backend).toBe(ISOLATE_SHELL_BACKEND); + }); + + test("cloneRepo skips the clone when the durable VFS already holds a usable one", async () => { + const iso = fakeIsolate({ + readdir: async () => [{ name: "HEAD", isDirectory: false }], + }); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines, + repoDir: "/repo", + }); + + await env.cloneRepo({ + url: "https://github.com/emdash-cms/emdash.git", + dir: "/workspace/repo", + }); + + expect(iso.execs.map((e) => e.source)).toEqual(["git status --porcelain"]); + expect(iso.execs[0]?.options.cwd).toBe("/workspace/repo"); + }); + + test("cloneRepo discards an unusable partial clone and re-clones", async () => { + const removed: string[] = []; + const iso = fakeIsolate({ + readdir: async () => [{ name: "HEAD", isDirectory: false }], + rm: async (path) => { + removed.push(path); + }, + }); + iso.setExecResult({ exitCode: 128, stdout: "", stderr: "fatal: not a git repository" }); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines, + repoDir: "/repo", + }); + + await expect( + env.cloneRepo({ url: "https://github.com/x/y.git", dir: "/workspace/repo" }), + ).rejects.toThrow("git clone failed (128)"); + expect(removed).toEqual(["/workspace/repo"]); + expect(iso.execs.map((e) => e.source)).toEqual([ + "git status --porcelain", + "git clone --depth 50 'https://github.com/x/y.git' '/workspace/repo'", + ]); + }); + + test("cloneRepo throws when the clone exits non-zero", async () => { + const iso = fakeIsolate(emptyVfs); + iso.setExecResult({ exitCode: 128, stdout: "", stderr: "fatal: repository not found" }); + const env = new ExecEnv({ + isolate: iso.isolate, + attachContainer: async () => fakeContainer().container, + deadlines, + repoDir: "/repo", + }); + + await expect( + env.cloneRepo({ url: "https://github.com/x/y.git", dir: "/workspace/repo" }), + ).rejects.toThrow("git clone failed (128)"); + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); diff --git a/infra/emdash-bot/tests/unit/sandbox-deadline.test.ts b/infra/emdash-bot/tests/unit/sandbox-deadline.test.ts index b7c370a3b1..26b1baf8b1 100644 --- a/infra/emdash-bot/tests/unit/sandbox-deadline.test.ts +++ b/infra/emdash-bot/tests/unit/sandbox-deadline.test.ts @@ -1,7 +1,6 @@ -import type { SandboxFactory, SessionEnv } from "@flue/runtime"; import { describe, expect, test } from "vitest"; -import { withDeadline, withSandboxDeadlines } from "../../.flue/lib/sandbox-deadline.js"; +import { withDeadline } from "../../.flue/lib/sandbox-deadline.js"; describe("withDeadline", () => { test("preserves a completed operation", async () => { @@ -14,48 +13,3 @@ describe("withDeadline", () => { ); }); }); - -describe("withSandboxDeadlines", () => { - test("applies the default deadline to file operations", async () => { - const sandbox = await withSandboxDeadlines( - factoryWith({ readFile: () => new Promise(() => {}) }), - { - defaultTimeoutMs: 10, - execGraceMs: 5, - }, - ).createSandbox({ id: "test" }); - - await expect(sandbox.readFile("stuck.txt")).rejects.toThrow( - "Sandbox readFile timed out after 10ms", - ); - }); - - test("adds grace to an exec operation's native timeout", async () => { - const sandbox = await withSandboxDeadlines(factoryWith({ exec: () => new Promise(() => {}) }), { - defaultTimeoutMs: 100, - execGraceMs: 5, - }).createSandbox({ id: "test" }); - - await expect(sandbox.exec("sleep forever", { timeoutMs: 10 })).rejects.toThrow( - "Sandbox exec timed out after 15ms", - ); - }); -}); - -function factoryWith(overrides: Partial): SandboxFactory { - const session: SessionEnv = { - exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - readFile: async () => "", - readFileBuffer: async () => new Uint8Array(), - writeFile: async () => undefined, - stat: async () => ({ isFile: true, isDirectory: false }), - readdir: async () => [], - exists: async () => false, - mkdir: async () => undefined, - rm: async () => undefined, - cwd: "/workspace", - resolvePath: (path) => path, - ...overrides, - }; - return { createSandbox: async () => session }; -} diff --git a/infra/emdash-bot/vitest.workers.config.ts b/infra/emdash-bot/vitest.workers.config.ts index bc5f46b4e8..ff81e16b14 100644 --- a/infra/emdash-bot/vitest.workers.config.ts +++ b/infra/emdash-bot/vitest.workers.config.ts @@ -23,10 +23,23 @@ */ import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin } from "vitest/config"; + +// The Flue Vite plugin transforms `SKILL.md` directory imports into skill +// references at build time; this pool doesn't load it, and the integration +// tests never run the agent, so stub the imports to keep the bundle parseable. +const stubSkillMd: Plugin = { + name: "stub-skill-md", + enforce: "pre", + load(id) { + if (!id.endsWith("/SKILL.md")) return null; + return "export default { __flueSkillReference: true, id: 'stub', name: 'stub', description: 'stub' };"; + }, +}; export default defineConfig({ plugins: [ + stubSkillMd, cloudflareTest({ wrangler: { configPath: "./wrangler.test.jsonc" }, miniflare: { diff --git a/infra/emdash-bot/worker-configuration.d.ts b/infra/emdash-bot/worker-configuration.d.ts index 41932d9692..e7974c097f 100644 --- a/infra/emdash-bot/worker-configuration.d.ts +++ b/infra/emdash-bot/worker-configuration.d.ts @@ -1,8 +1,9 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: dd5f1d156ce43da91700fa228028f34b) -// Runtime types generated with workerd@1.20260611.1 2026-07-17 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 5458f09a5c2c5fd92d962f85a542aadb) +// Runtime types generated with workerd@1.20260611.1 2026-07-17 experimental,nodejs_compat interface __BaseEnv_Env { BOT_WORKSPACE: R2Bucket; + LOADER: WorkerLoader; AI: Ai; GITHUB_APP_ID: "3255304"; GITHUB_APP_INSTALLATION_ID: "120963314"; @@ -12,6 +13,7 @@ interface __BaseEnv_Env { GITHUB_APP_PRIVATE_KEY: string; Sandbox: DurableObjectNamespace /* Sandbox */; Orchestrator: DurableObjectNamespace /* OrchestratorDO */; + WorkspaceDO: DurableObjectNamespace /* WorkspaceDO */; } declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} @@ -448,6 +450,7 @@ interface ExecutionContext { readonly props: Props; cache?: CacheContext; tracing?: Tracing; + abort(reason?: any): void; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -541,9 +544,15 @@ type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; routingMode?: DurableObjectRoutingMode; + version?: { + cohort?: string; + }; } interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { } +interface DurableObjectNamespaceGetDurableObjectOptionsVersionOptions { + cohort?: string; +} interface DurableObjectState { waitUntil(promise: Promise): void; readonly exports: Cloudflare.Exports; @@ -552,6 +561,7 @@ interface DurableObjectState { readonly storage: DurableObjectStorage; container?: Container; facets: DurableObjectFacets; + readonly primaryStub?: DurableObjectStub; blockConcurrencyWhile(callback: () => Promise): Promise; acceptWebSocket(ws: WebSocket, tags?: string[]): void; getWebSockets(tag?: string): WebSocket[]; @@ -596,6 +606,9 @@ interface DurableObjectStorage { getCurrentBookmark(): Promise; getBookmarkForTime(timestamp: number | Date): Promise; onNextSessionRestoreBookmark(bookmark: string): Promise; + waitForBookmark(bookmark: string): Promise; + /** @deprecated Use `ctx.primaryStub` instead. */ + readonly primary?: DurableObjectStub; } interface DurableObjectListOptions { start?: string; @@ -1903,6 +1916,7 @@ interface KVNamespace { getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>>; getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>>; delete(key: Key): Promise; + deleteBulk(keys: Key | Key[]): Promise; } interface KVNamespaceListOptions { limit?: number; @@ -2140,6 +2154,16 @@ type R2Objects = { interface R2UploadPartOptions { ssecKey?: (ArrayBuffer | string); } +declare abstract class JsRpcPromise { + then(handler: Function, errorHandler?: Function): any; + catch(errorHandler: Function): any; + finally(onFinally: Function): any; +} +declare abstract class JsRpcProperty { + then(handler: Function, errorHandler?: Function): any; + catch(errorHandler: Function): any; + finally(onFinally: Function): any; +} declare abstract class ScheduledEvent extends ExtendableEvent { readonly scheduledTime: number; readonly cron: string; @@ -3206,6 +3230,9 @@ declare const WebSocketPair: { }; interface SqlStorage { exec>(query: string, ...bindings: any[]): SqlStorageCursor; + prepare(query: string): SqlStorageStatement; + ingest(query: string): SqlStorageIngestResult; + setMaxPageCountForTest(count: number): void; get databaseSize(): number; Cursor: typeof SqlStorageCursor; Statement: typeof SqlStorageStatement; @@ -3227,8 +3254,15 @@ declare abstract class SqlStorageCursor; } +interface SqlStorageIngestResult { + remainder: string; + rowsRead: number; + rowsWritten: number; + statementCount: number; +} interface Socket { get readable(): ReadableStream; get writable(): WritableStream; @@ -3307,6 +3341,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3320,6 +3376,9 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; snapshotContainer(options: ContainerSnapshotOptions): Promise; interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; + interceptOutboundTcp(addr: string, binding: Fetcher): Promise; + inspect(): Promise; } interface ContainerDirectorySnapshot { id: string; @@ -3347,10 +3406,14 @@ interface ContainerStartupOptions { entrypoint?: string[]; enableInternet: boolean; env?: Record; + hardTimeout?: number | bigint; labels?: Record; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; containerSnapshot?: ContainerSnapshot; } +interface ContainerInfo { + labels: Record; +} /** * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. * diff --git a/infra/emdash-bot/wrangler.jsonc b/infra/emdash-bot/wrangler.jsonc index 40b6b8a420..a4b75e77fd 100644 --- a/infra/emdash-bot/wrangler.jsonc +++ b/infra/emdash-bot/wrangler.jsonc @@ -3,7 +3,9 @@ "name": "emdash-bot", "account_id": "1f74638c495bc9f0330ce5c8e64c1b6b", "compatibility_date": "2026-07-17", - "compatibility_flags": ["nodejs_compat"], + // `experimental` is required by @cloudflare/computer's worker-shell backend + // (Dynamic Workers via the Worker Loader binding). + "compatibility_flags": ["nodejs_compat", "experimental"], // Workers AI: structural binding, no API key. Every model the bot uses // (classify, investigate, fix) resolves through `cloudflare/@cf/...` @@ -35,9 +37,21 @@ "class_name": "OrchestratorDO", "name": "Orchestrator", }, + { + "class_name": "WorkspaceDO", + "name": "WorkspaceDO", + }, ], }, + // Dynamic Worker loader for @cloudflare/computer's worker-shell isolate + // backend (bash-in-isolate). Requires the `experimental` compat flag above. + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], + // Every Durable Object class the worker uses must be declared sqlite-enabled. // Flue's run-index (FlueRegistry) and per-workflow DOs (FlueClassifyCommand- // Workflow, FlueInvestigateWorkflow) all use SQL storage internally, so they @@ -64,6 +78,10 @@ "new_sqlite_classes": ["FlueClassifyCommandAgent", "FlueInvestigateAgent"], "deleted_classes": ["FlueRegistry", "FlueClassifyCommandWorkflow", "FlueInvestigateWorkflow"], }, + { + "tag": "v4", + "new_sqlite_classes": ["WorkspaceDO"], + }, ], // Workspace storage for the sandbox (repo clone, build cache, agent files diff --git a/infra/emdash-bot/wrangler.test.jsonc b/infra/emdash-bot/wrangler.test.jsonc index c70d0576af..d2f2f5f0d5 100644 --- a/infra/emdash-bot/wrangler.test.jsonc +++ b/infra/emdash-bot/wrangler.test.jsonc @@ -1,7 +1,8 @@ { // Test-only wrangler config consumed by @cloudflare/vitest-pool-workers via // vitest.workers.config.ts. Mirrors production bindings for the - // user-owned DOs (Sandbox, OrchestratorDO) and the AI / R2 surfaces but + // user-owned DOs (Sandbox, OrchestratorDO, WorkspaceDO) and the AI / R2 + // surfaces but // skips the Flue-generated workflow DOs (FlueRegistry, FlueClassify- // CommandWorkflow, ...) because the test entry below doesn't go through // Flue routing. Workflow integration tests need a separate harness once @@ -14,7 +15,7 @@ "name": "emdash-bot-test", "account_id": "1f74638c495bc9f0330ce5c8e64c1b6b", "compatibility_date": "2026-04-01", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": ["nodejs_compat", "experimental"], // The test pool dispatches to this entry. It re-exports the DOs and // provides a minimal fetch handler so SELF.fetch() works in tests. @@ -44,13 +45,23 @@ "class_name": "OrchestratorDO", "name": "Orchestrator", }, + { + "class_name": "WorkspaceDO", + "name": "WorkspaceDO", + }, ], }, + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], + "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["Sandbox", "OrchestratorDO"], + "new_sqlite_classes": ["Sandbox", "OrchestratorDO", "WorkspaceDO"], }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fea94d37f2..e82219075a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1027,6 +1027,9 @@ importers: infra/emdash-bot: dependencies: + '@cloudflare/computer': + specifier: 0.1.1 + version: 0.1.1(zod@4.4.1) '@cloudflare/sandbox': specifier: ^0.12.1 version: 0.12.3 @@ -1035,7 +1038,7 @@ importers: version: 2.0.3(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(typescript@6.0.3)(ws@8.21.1)(zod@4.4.1) agents: specifier: ^0.20.1 - version: 0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.4.1))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1) + version: 0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1) hono: specifier: 4.12.27 version: 4.12.27 @@ -1051,7 +1054,7 @@ importers: version: 0.16.3(@cloudflare/workers-types@4.20260305.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) '@flue/vite': specifier: 2.0.3 - version: 2.0.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.4.1))(hono@4.12.27)(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(ws@8.21.1)(zod@4.4.1) + version: 2.0.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(hono@4.12.27)(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(ws@8.21.1)(zod@4.4.1) typescript: specifier: 'catalog:' version: 6.0.3 @@ -3844,6 +3847,20 @@ packages: zod: optional: true + '@cloudflare/computer@0.1.1': + resolution: {integrity: sha512-4xWx5yX+y5MyNhtIK9N6LcyBsbzGdaxGbhfZdh51zRWRfdkI8OhL2ftBCReqaR6HD7we+Yg0rEihwBETrmkbmg==} + peerDependencies: + '@platformatic/vfs': '*' + ai: ^6.0.196 || ^7.0.0 + zod: ^4.4.3 + peerDependenciesMeta: + '@platformatic/vfs': + optional: true + ai: + optional: true + zod: + optional: true + '@cloudflare/containers@0.3.7': resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} @@ -14585,6 +14602,16 @@ snapshots: ai: 6.0.172(zod@4.4.1) zod: 4.4.1 + '@cloudflare/computer@0.1.1(zod@4.4.1)': + dependencies: + acorn: 8.17.0 + capnweb: 0.8.0 + just-bash: 3.0.1 + optionalDependencies: + zod: 4.4.1 + transitivePeerDependencies: + - supports-color + '@cloudflare/containers@0.3.7': {} '@cloudflare/kumo@2.6.0(@date-fns/tz@1.4.1)(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': @@ -15495,10 +15522,10 @@ snapshots: '@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.1) '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1) '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@6.0.3)) hono: 4.12.27 - hono-openapi: 1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + hono-openapi: 1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) js-yaml: 4.2.0 just-bash: 3.0.1 openapi-types: 12.1.3 @@ -15546,11 +15573,11 @@ snapshots: dependencies: '@durable-streams/client': 0.2.6 - '@flue/vite@2.0.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.4.1))(hono@4.12.27)(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(ws@8.21.1)(zod@4.4.1)': + '@flue/vite@2.0.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(hono@4.12.27)(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(ws@8.21.1)(zod@4.4.1)': dependencies: '@flue/runtime': 2.0.3(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(typescript@6.0.3)(ws@8.21.1)(zod@4.4.1) '@hono/node-server': 2.0.4(hono@4.12.27) - agents: 0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.4.1))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1) + agents: 0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1) magic-string: 1.1.0 tinyglobby: 0.2.17 ulidx: 2.4.1 @@ -17629,18 +17656,6 @@ snapshots: '@speed-highlight/core@1.2.14': {} - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/json-schema': 7.0.15 - quansync: 0.2.11 - optionalDependencies: - '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@6.0.3)) - typebox: 1.1.38 - valibot: 1.4.1(typescript@6.0.3) - zod: 4.4.1 - zod-to-json-schema: 3.25.1(zod@4.4.1) - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': dependencies: '@standard-schema/spec': 1.1.0 @@ -17653,19 +17668,9 @@ snapshots: zod: 4.4.1 zod-to-json-schema: 3.25.1(zod@4.4.1) - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1)': - dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) - '@standard-schema/spec': 1.1.0 - openapi-types: 12.1.3 - optionalDependencies: - typebox: 1.1.38 - valibot: 1.4.1(typescript@6.0.3) - zod: 4.4.1 - - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1)': + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1)': dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 optionalDependencies: @@ -18678,7 +18683,7 @@ snapshots: - rolldown - supports-color - agents@0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(ai@6.0.172(zod@4.4.1))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1): + agents@0.20.1(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@cloudflare/workers-types@4.20260305.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.1))(@modelcontextprotocol/server@2.0.0)(@x402/core@2.8.0)(@x402/evm@2.8.0(typescript@6.0.3))(just-bash@3.0.1)(react@19.2.4)(rolldown@1.0.3)(vite@8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))(zod@4.4.1): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@7.29.7) '@cfworker/json-schema': 4.1.1 @@ -18699,7 +18704,6 @@ snapshots: optionalDependencies: '@x402/core': 2.8.0 '@x402/evm': 2.8.0(typescript@6.0.3) - ai: 6.0.172(zod@4.4.1) just-bash: 3.0.1 vite: 8.0.11(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -18797,7 +18801,7 @@ snapshots: astro-auto-import@0.4.6(astro@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(rollup@4.55.2)(tsx@4.21.0)(yaml@2.9.0)): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 astro: 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(rollup@4.55.2)(tsx@4.21.0)(yaml@2.9.0) astro-embed@0.12.0(astro@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(rollup@4.55.2)(tsx@4.21.0)(yaml@2.9.0)): @@ -20626,10 +20630,10 @@ snapshots: highlight.js@10.7.3: {} - hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3): + hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3): dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1) '@types/json-schema': 7.0.15 openapi-types: 12.1.3 optionalDependencies: @@ -20943,7 +20947,7 @@ snapshots: just-bash@3.0.1: dependencies: - diff: 8.0.3 + diff: 8.0.4 fast-xml-parser: 5.8.0 file-type: 21.3.4 ini: 6.0.0 @@ -21757,7 +21761,7 @@ snapshots: mlly@1.8.2: dependencies: - acorn: 8.16.0 + acorn: 8.17.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.3