mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
Add runtime asset build-gap guard
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server package ships runtime asset trees used by built-in agents
and onboarding templates.
> - A prior server build omitted those source asset trees from `dist`,
allowing a built artifact to differ from runtime expectations.
> - The copy step is now present, but the existing build-gap gate only
checked TypeScript coverage for packages whose build skips `tsc`.
> - This pull request extends that standing gate so source asset files
under the server runtime asset trees must exist at the matching `dist`
paths after build.
> - The benefit is that future server asset additions fail loudly in CI
instead of silently shipping an incomplete `dist`.
## Linked Issues or Issue Description
### What happened?
After a server build, runtime asset files under
`server/src/built-ins/**` and `server/src/onboarding-assets/**` could be
missing from `dist/` with no build failure. The existing build-gap gate
only checked TypeScript coverage for packages that skip `tsc`; it did
not verify that non-TypeScript source assets were copied to `dist`. A
server build that forgot the `cp -R` step, or that added a new asset
tree without updating the copy command, would produce an incomplete
`dist` without any CI signal.
### Expected behavior
After `pnpm --filter @paperclipai/server build`, every
non-TypeScript/non-JavaScript source file under `server/src/built-ins/`
and `server/src/onboarding-assets/` must exist at the matching path
under `server/dist/`. If any file is missing, the build-gap gate must
exit non-zero with a diagnostic listing the missing files and the
command to fix them.
### Steps to reproduce
1. Remove a copied runtime asset: `rm
server/dist/built-ins/agents/reflection-coach/AGENTS.md`
2. Run the guard: `node scripts/run-typecheck-build-gaps.mjs
--runtime-assets-only`
3. Before this fix: the command exits 0 and the missing file goes
undetected.
### Paperclip version or commit
Reproduced on `master` at `c36f1a4af` (`@paperclipai/server` 0.3.1).
### Deployment mode
Not deployment-specific — the build-gap check runs in CI on any
checkout.
## What Changed
- Extended `scripts/run-typecheck-build-gaps.mjs` with a source-derived
server runtime asset parity check for non-`.ts`/non-`.js` files under
`server/src/built-ins/**` and `server/src/onboarding-assets/**`.
- Added a guard-only mode, `--runtime-assets-only`, for focused
pass/fail verification after a server build.
- Wired `pnpm run typecheck:build-gaps` to prepare plugin SDK build
deps, build the server package, then run the existing build-gap gate
plus the new asset check.
## Verification
Pass path:
```text
$ pnpm --filter @paperclipai/plugin-sdk ensure-build-deps
> @paperclipai/plugin-sdk@1.0.0 ensure-build-deps .../packages/plugins/sdk
> node ../../../scripts/ensure-plugin-build-deps.mjs
$ pnpm --filter @paperclipai/server build
> @paperclipai/server@0.3.1 build .../server
> tsc && mkdir -p dist/onboarding-assets dist/built-ins && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/
$ node scripts/run-typecheck-build-gaps.mjs --runtime-assets-only
[typecheck:build-gaps] server runtime assets present in dist: 7 file(s)
```
Regression simulation (guard catches the missing file):
```text
$ rm server/dist/built-ins/agents/reflection-coach/AGENTS.md
$ node scripts/run-typecheck-build-gaps.mjs --runtime-assets-only
[typecheck:build-gaps] Missing server runtime asset(s) in dist:
- source: server/src/built-ins/agents/reflection-coach/AGENTS.md
expected dist: server/dist/built-ins/agents/reflection-coach/AGENTS.md
Run pnpm --filter @paperclipai/server build and ensure source runtime asset trees are copied into dist.
```
Standing gate (full end-to-end):
```text
$ pnpm run typecheck:build-gaps
[typecheck:build-gaps] typechecking 4 workspace(s): paperclipai, @paperclipai/plugin-authoring-smoke-example, @paperclipai/plugin-llm-wiki, @paperclipai/ui
[typecheck:build-gaps] server runtime assets present in dist: 7 file(s)
```
## Risks
Low risk. The check only reads source and dist files during the
build-gap gate. The main tradeoff is that the gate now builds
`@paperclipai/server` so a clean checkout has generated `dist` content
to validate.
## Model Used
OpenAI Codex, GPT-5 based coding agent with repository tool use and
shell execution.
## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c36f1a4afd
commit
8775bde4ce
3 files changed
+162
-9
No files matched your search
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
|
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
|
||||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||||
"typecheck:build-gaps": "pnpm run preflight:workspace-links && node scripts/run-typecheck-build-gaps.mjs",
|
"typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs",
|
||||||
"test": "pnpm run test:run",
|
"test": "pnpm run test:run",
|
||||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const script = new URL("../run-typecheck-build-gaps.mjs", import.meta.url).pathname;
|
||||||
|
|
||||||
|
function createFixtureRepo() {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "run-typecheck-build-gaps-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFixtureFile(root, relativePath, body = "fixture") {
|
||||||
|
const filePath = path.join(root, relativePath);
|
||||||
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
writeFileSync(filePath, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRuntimeAssetGuard(root) {
|
||||||
|
return spawnSync(process.execPath, [script, "--runtime-assets-only"], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("passes when all source runtime assets are present in dist", () => {
|
||||||
|
const root = createFixtureRepo();
|
||||||
|
try {
|
||||||
|
writeFixtureFile(root, "server/src/built-ins/agents/default.md");
|
||||||
|
writeFixtureFile(root, "server/dist/built-ins/agents/default.md");
|
||||||
|
writeFixtureFile(root, "server/src/onboarding-assets/welcome.txt");
|
||||||
|
writeFixtureFile(root, "server/dist/onboarding-assets/welcome.txt");
|
||||||
|
writeFixtureFile(root, "server/src/built-ins/ignored.ts");
|
||||||
|
|
||||||
|
const result = runRuntimeAssetGuard(root);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /server runtime assets present in dist: 2 file\(s\)/);
|
||||||
|
assert.equal(result.stderr, "");
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails with the missing source asset and expected dist path", () => {
|
||||||
|
const root = createFixtureRepo();
|
||||||
|
try {
|
||||||
|
writeFixtureFile(root, "server/src/built-ins/agents/default.md");
|
||||||
|
|
||||||
|
const result = runRuntimeAssetGuard(root);
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /Missing server runtime asset\(s\) in dist/);
|
||||||
|
assert.match(result.stderr, /source: server\/src\/built-ins\/agents\/default\.md/);
|
||||||
|
assert.match(result.stderr, /expected dist: server\/dist\/built-ins\/agents\/default\.md/);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const allowedArgs = new Set(["--runtime-assets-only"]);
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (!allowedArgs.has(arg)) {
|
||||||
|
fail(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
console.error(`[typecheck:build-gaps] ${message}`);
|
console.error(`[typecheck:build-gaps] ${message}`);
|
||||||
@@ -30,6 +38,91 @@ function readJson(filePath) {
|
|||||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPath(filePath) {
|
||||||
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFilesRecursive(rootDir) {
|
||||||
|
if (!existsSync(rootDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
const entries = readdirSync(rootDir, { withFileTypes: true }).sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(rootDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...listFilesRecursive(entryPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuntimeAsset(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return !new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]).has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkServerRuntimeAssets() {
|
||||||
|
const runtimeAssetTrees = [
|
||||||
|
{
|
||||||
|
sourceDir: path.join(repoRoot, "server", "src", "built-ins"),
|
||||||
|
distDir: path.join(repoRoot, "server", "dist", "built-ins"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceDir: path.join(repoRoot, "server", "src", "onboarding-assets"),
|
||||||
|
distDir: path.join(repoRoot, "server", "dist", "onboarding-assets"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const missingAssets = [];
|
||||||
|
let checkedCount = 0;
|
||||||
|
|
||||||
|
for (const assetTree of runtimeAssetTrees) {
|
||||||
|
const sourceAssets = listFilesRecursive(assetTree.sourceDir).filter(isRuntimeAsset);
|
||||||
|
checkedCount += sourceAssets.length;
|
||||||
|
|
||||||
|
for (const sourcePath of sourceAssets) {
|
||||||
|
const relativeAssetPath = path.relative(assetTree.sourceDir, sourcePath);
|
||||||
|
const distPath = path.join(assetTree.distDir, relativeAssetPath);
|
||||||
|
|
||||||
|
if (!existsSync(distPath) || !statSync(distPath).isFile()) {
|
||||||
|
missingAssets.push({ sourcePath, distPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingAssets.length > 0) {
|
||||||
|
const missingList = missingAssets
|
||||||
|
.map(
|
||||||
|
({ sourcePath, distPath }) =>
|
||||||
|
` - source: ${formatPath(sourcePath)}\n expected dist: ${formatPath(distPath)}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
fail(
|
||||||
|
`Missing server runtime asset(s) in dist:\n${missingList}\nRun pnpm --filter @paperclipai/server build and ensure source runtime asset trees are copied into dist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[typecheck:build-gaps] server runtime assets present in dist: ${checkedCount} file(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.has("--runtime-assets-only")) {
|
||||||
|
checkServerRuntimeAssets();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
function listWorkspacePackages() {
|
function listWorkspacePackages() {
|
||||||
const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], {
|
const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
@@ -82,12 +175,12 @@ console.log(
|
|||||||
`[typecheck:build-gaps] typechecking ${buildGapPackages.length} workspace(s): ${buildGapPackages.map(({ name }) => name).join(", ") || "(none)"}`,
|
`[typecheck:build-gaps] typechecking ${buildGapPackages.length} workspace(s): ${buildGapPackages.map(({ name }) => name).join(", ") || "(none)"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (buildGapPackages.length === 0) {
|
if (buildGapPackages.length > 0) {
|
||||||
process.exit(0);
|
run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]);
|
||||||
|
|
||||||
|
for (const workspacePkg of buildGapPackages) {
|
||||||
|
run("pnpm", ["--filter", workspacePkg.name, "typecheck"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]);
|
checkServerRuntimeAssets();
|
||||||
|
|
||||||
for (const workspacePkg of buildGapPackages) {
|
|
||||||
run("pnpm", ["--filter", workspacePkg.name, "typecheck"]);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user