diff --git a/tests/restart-chaos/main.ts b/tests/restart-chaos/main.ts index b8348803c6..97e64d5ce0 100644 --- a/tests/restart-chaos/main.ts +++ b/tests/restart-chaos/main.ts @@ -7,7 +7,7 @@ * node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --replay / */ import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -15,6 +15,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { ChaosApi, isSocketTypeOfServiceRace } from "./api.js"; import { CREDENTIAL_FOR_ADAPTER, parseCommand, replayCommand, waveIdFor } from "./config.js"; import { assertControllerMatchesImage, prepareDaytonaImageArtifacts } from "./daytona-image.js"; +import { PLUGIN_BUILD_STAMP, pluginBuildState } from "./plugin-build.js"; import { DaytonaWaveSweeper, leaksForTask, unattributedLeaks, type DaytonaSweepResult } from "./daytona-sweep.js"; import { knownSecretValues, writeEvidenceBundle } from "./evidence.js"; import { gradeTask } from "./grader.js"; @@ -59,13 +60,27 @@ function run(command: string, args: string[], cwd: string, label: string) { if (result.status !== 0) throw new Error(`${label} failed (${command} ${args.join(" ")})`); } -/** The bundled Daytona plugin is outside the root workspace; prepare it as CI does. */ -function ensureDaytonaPlugin() { +/** + * The bundled Daytona plugin is outside the root workspace; prepare it as CI + * does, and rebuild it whenever its sources (or the plugin SDK's) changed since + * the last build, so a worktree that moved to a new master tests that master's + * plugin rather than an older build. + */ +function ensureDaytonaPlugin(timeline: Timeline) { const root = path.join(repositoryRoot, "packages/plugins/sandbox-providers/daytona"); - if (["dist/manifest.js", "dist/worker.js", "node_modules/@daytonaio/sdk"].every(file => existsSync(path.join(root, file)))) return; + // Inputs outside the plugin directory: the linked plugin SDK and the tsconfig chain both packages extend. + const extraInputs = [ + ...["src", "package.json", "tsconfig.json"].map(file => path.join(repositoryRoot, "packages/plugins/sdk", file)), + ...["tsconfig.json", "tsconfig.base.json"].map(file => path.join(repositoryRoot, file)), + ]; + const state = pluginBuildState(root, extraInputs); + timeline.add("daytona_plugin_build_state", { reason: state.reason, inputHash: state.inputHash }); + if (!state.needsBuild) return; run("pnpm", ["install", "--ignore-workspace", "--frozen-lockfile", "--ignore-scripts"], root, "Daytona plugin install"); run(process.execPath, [path.join(repositoryRoot, "scripts/link-plugin-dev-sdk.mjs")], repositoryRoot, "Plugin SDK link"); run("pnpm", ["--dir", root, "build"], repositoryRoot, "Daytona plugin build"); + writeFileSync(path.join(root, PLUGIN_BUILD_STAMP), `${state.inputHash}\n`); + timeline.add("daytona_plugin_built", { inputHash: state.inputHash }); } /** @@ -80,7 +95,7 @@ async function prepareDaytona(plan: WavePlan, timeline: Timeline): Promise { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +async function plugin(): Promise<{ root: string; sdk: string }> { + const base = await mkdtemp(path.join(os.tmpdir(), "chaos-plugin-")); + roots.push(base); + const root = path.join(base, "daytona"); + const sdk = path.join(base, "sdk-src"); + for (const [file, content] of Object.entries({ + "package.json": "{\"name\":\"@paperclipai/plugin-daytona\"}", "pnpm-lock.yaml": "lock: 1\n", "tsconfig.json": "{}", + "src/plugin.ts": "export const v = 1;\n", "src/nested/worker.ts": "export {};\n", + "dist/manifest.js": "", "dist/worker.js": "", "node_modules/@daytonaio/sdk/package.json": "{}", + "node_modules/ignored/index.js": "ignored", + })) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await writeFile(path.join(root, file), content); + } + await mkdir(sdk, { recursive: true }); + await writeFile(path.join(sdk, "index.ts"), "export const sdk = 1;\n"); + return { root, sdk }; +} + +const stamp = (root: string, hash: string) => writeFile(path.join(root, PLUGIN_BUILD_STAMP), `${hash}\n`); + +describe("Daytona plugin build staleness", () => { + it("rebuilds an unstamped build, then reports up to date once stamped with its inputs", async () => { + const { root, sdk } = await plugin(); + const first = pluginBuildState(root, [sdk]); + expect(first).toMatchObject({ needsBuild: true, needsInstall: false, reason: "build was not stamped with its inputs" }); + await stamp(root, first.inputHash); + expect(pluginBuildState(root, [sdk])).toMatchObject({ needsBuild: false, reason: "up to date" }); + }); + + it("detects a changed plugin source, lockfile or plugin SDK source after a build", async () => { + const { root, sdk } = await plugin(); + await stamp(root, pluginBuildInputHash(root, [sdk])); + await writeFile(path.join(root, "src/plugin.ts"), "export const v = 2;\n"); + expect(pluginBuildState(root, [sdk])).toMatchObject({ needsBuild: true, reason: "plugin sources changed since the last build" }); + + await stamp(root, pluginBuildInputHash(root, [sdk])); + await writeFile(path.join(root, "pnpm-lock.yaml"), "lock: 2\n"); + expect(pluginBuildState(root, [sdk]).needsBuild).toBe(true); + + await stamp(root, pluginBuildInputHash(root, [sdk])); + await writeFile(path.join(sdk, "index.ts"), "export const sdk = 2;\n"); + expect(pluginBuildState(root, [sdk]).needsBuild).toBe(true); + }); + + it("detects a change to an inherited tsconfig passed as a single-file extra input", async () => { + const { root, sdk } = await plugin(); + const baseConfig = path.join(path.dirname(root), "tsconfig.base.json"); + await writeFile(baseConfig, "{\"compilerOptions\":{\"target\":\"ES2022\"}}"); + await stamp(root, pluginBuildInputHash(root, [sdk, baseConfig])); + expect(pluginBuildState(root, [sdk, baseConfig]).needsBuild).toBe(false); + await writeFile(baseConfig, "{\"compilerOptions\":{\"target\":\"ES2024\"}}"); + expect(pluginBuildState(root, [sdk, baseConfig])).toMatchObject({ needsBuild: true, reason: "plugin sources changed since the last build" }); + }); + + it("ignores node_modules and build output, and requires dependencies and outputs", async () => { + const { root, sdk } = await plugin(); + const hash = pluginBuildInputHash(root, [sdk]); + await writeFile(path.join(root, "node_modules/ignored/index.js"), "changed"); + await writeFile(path.join(root, "dist/worker.js"), "changed"); + expect(pluginBuildInputHash(root, [sdk])).toBe(hash); + await stamp(root, hash); + await rm(path.join(root, "dist/manifest.js")); + expect(pluginBuildState(root, [sdk])).toMatchObject({ needsBuild: true, reason: "build output missing" }); + await rm(path.join(root, "node_modules/@daytonaio"), { recursive: true }); + expect(pluginBuildState(root, [sdk])).toMatchObject({ needsInstall: true, reason: "dependencies missing" }); + }); +}); diff --git a/tests/restart-chaos/plugin-build.ts b/tests/restart-chaos/plugin-build.ts new file mode 100644 index 0000000000..8a1f2956bd --- /dev/null +++ b/tests/restart-chaos/plugin-build.ts @@ -0,0 +1,63 @@ +/** + * The bundled Daytona plugin lives outside the pnpm workspace, so workspace + * builds and `agent-preflight` never rebuild it. A worktree that moves to a new + * master would otherwise keep serving the plugin built for an older commit, + * and plugin-side fixes would silently go untested. + * + * The build is keyed by a content hash of the plugin's inputs, recorded next + * to its output. + */ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +export const PLUGIN_BUILD_STAMP = "dist/.restart-chaos-build-input.sha256"; +const INPUT_FILES = ["package.json", "pnpm-lock.yaml", "tsconfig.json"]; +const INPUT_DIRECTORIES = ["src"]; + +function listFiles(root: string, relative: string): string[] { + const directory = path.join(root, relative); + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const child = path.posix.join(relative, entry.name); + if (entry.isDirectory()) return entry.name === "node_modules" ? [] : listFiles(root, child); + return entry.isFile() ? [child] : []; + }); +} + +/** sha256 over the plugin's build inputs: paths and contents, in sorted order. */ +export function pluginBuildInputHash(pluginRoot: string, extraInputs: readonly string[] = []): string { + const hash = createHash("sha256"); + const files = [...INPUT_FILES.filter(file => existsSync(path.join(pluginRoot, file))), + ...INPUT_DIRECTORIES.flatMap(directory => listFiles(pluginRoot, directory))].sort(); + for (const file of files) { + hash.update(`${file}\0`); + hash.update(readFileSync(path.join(pluginRoot, file))); + hash.update("\0"); + } + // Inputs outside the plugin directory (for example the linked plugin SDK) are hashed as given. + for (const extra of [...extraInputs].sort()) { + hash.update(`extra:${extra}\0`); + for (const file of statSync(extra, { throwIfNoEntry: false })?.isDirectory() ? listFiles(extra, "").sort() : [""]) { + const absolute = file ? path.join(extra, file) : extra; + if (!existsSync(absolute)) continue; + hash.update(`${file}\0`); + hash.update(readFileSync(absolute)); + } + } + return hash.digest("hex"); +} + +export interface PluginBuildState { needsInstall: boolean; needsBuild: boolean; inputHash: string; reason: string } + +/** Whether the installed and built plugin matches its current sources. */ +export function pluginBuildState(pluginRoot: string, extraInputs: readonly string[] = []): PluginBuildState { + const inputHash = pluginBuildInputHash(pluginRoot, extraInputs); + const needsInstall = !existsSync(path.join(pluginRoot, "node_modules/@daytonaio/sdk")); + const outputs = ["dist/manifest.js", "dist/worker.js"].every(file => existsSync(path.join(pluginRoot, file))); + const stamp = existsSync(path.join(pluginRoot, PLUGIN_BUILD_STAMP)) + ? readFileSync(path.join(pluginRoot, PLUGIN_BUILD_STAMP), "utf8").trim() : null; + const reason = needsInstall ? "dependencies missing" : !outputs ? "build output missing" + : stamp === null ? "build was not stamped with its inputs" : stamp !== inputHash ? "plugin sources changed since the last build" : "up to date"; + return { needsInstall, needsBuild: reason !== "up to date", inputHash, reason }; +}