Files
PaperClipAI/tests/restart-chaos/plugin-build.ts
T
DottaandClaude Opus 5.5 6fb6220d96 fix(restart-chaos): rebuild the Daytona plugin when its sources change (#19798)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness runs Daytona waves with a local control
plane that installs the bundled Daytona sandbox-provider plugin from
`packages/plugins/sandbox-providers/daytona`
> - That plugin is excluded from the pnpm workspace, so workspace builds
and `scripts/agent-preflight.mjs` never rebuild it
> - The harness built it only when `dist/` was missing. A long-lived
chaos worktree that moved across many master commits kept serving the
plugin it built on day one
> - As a result, plugin-side fixes such as #19696 (reclaim sandboxes
left by a restart during acquire) and #19754 (keep a runner sandbox
alive through the reconnect grace) were not under test in those waves,
while server and runner fixes were
> - This pull request keys the plugin build on a content hash of its
inputs and rebuilds when the hash changes
> - The benefit is that every Daytona wave tests the plugin of the
commit it claims to test

## Linked Issues or Issue Description

**Bug description**
`ensureDaytonaPlugin()` in `tests/restart-chaos/main.ts` returned early
whenever `dist/manifest.js`, `dist/worker.js` and the Daytona SDK
existed.

**Steps to reproduce**
Build the plugin on commit A, check out a later commit B that changes
`packages/plugins/sandbox-providers/daytona/src`, and run a `--env
daytona` wave. The wave installs the commit-A plugin. In one chaos
worktree, `dist/plugin.js` was 20 hours older than `src/plugin.ts` and
lacked `runnerReconnectGraceMs` from #19754.

**Expected behavior**
The harness rebuilds the plugin whenever its sources, lockfile or the
plugin SDK source changed since the last build.

Refs #19544

## What Changed

- `tests/restart-chaos/plugin-build.ts` (new): `pluginBuildInputHash`
hashes the plugin's `package.json`, `pnpm-lock.yaml`, `tsconfig.json`
and `src/**`, plus extra inputs: the plugin SDK `src`, `package.json`
and `tsconfig.json`, and the root `tsconfig.json` and
`tsconfig.base.json` that both packages extend. It excludes
`node_modules` and `dist`. `pluginBuildState` compares that hash with a
stamp in `dist/.restart-chaos-build-input.sha256` and reports a reason.
- `tests/restart-chaos/main.ts`: `ensureDaytonaPlugin` installs, links
the SDK and builds when the state is stale, then writes the stamp. It
records `daytona_plugin_build_state` and `daytona_plugin_built` in the
timeline.
- `tests/restart-chaos/plugin-build.test.ts` (new): an unstamped build
is stale; the stamped build is up to date; a changed source, lockfile or
SDK source is stale; changes to `node_modules` and `dist` are ignored;
missing outputs or dependencies are stale.

Other out-of-workspace packages, checked as requested:
- `pnpm-workspace.yaml` excludes only
`packages/plugins/sandbox-providers/**`.
- The restart chaos local mode (`--env local`) does not install a
sandbox provider.
- CI's runner-e2e "Prepare bundled Daytona plugin" step runs on a fresh
checkout per job, so it cannot see a stale `dist/`.
- The other sandbox providers are not used by these harnesses. The risk
is therefore limited to long-lived local worktrees running Daytona
waves, which this change covers.

## Verification

- `vitest run --config tests/restart-chaos/vitest.config.ts`: 121/121
pass, including the new staleness tests.
- `tsc -p tests/restart-chaos/tsconfig.json`: no errors in the changed
files.
- Manual: in the chaos worktree, `dist/plugin.js` (built 2026-11-06
11:16) lacked `runnerReconnectGraceMs`. After a rebuild at master
35d41f11bb it contains it.

## Risks

- This change touches the test harness only.
- The first wave after this change rebuilds the plugin once in each
worktree, because existing builds have no stamp. That takes a few
seconds.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, GitHub CLI). Default context window,
extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:44:25 -06:00

64 lines
3.2 KiB
TypeScript

/**
* 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 };
}