From e445b3e367c1222d920e6c9758424edb7691324f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 09:12:01 -0700 Subject: [PATCH] FN-8201: pin pi dependency versions Pin the pi runtime packages to a single exact version so global npm installs resolve a compatible set. - Pin pi-ai and pi-coding-agent declarations across workspace manifests - Add a guard and tests that reject ranged or mismatched pi versions - Document the source-install fallback and add a patch changeset Files changed: .changeset/fn-8201-pin-pi-versions.md | 7 ++ docs/getting-started.md | 3 + package.json | 6 +- packages/cli/package.json | 4 +- packages/cli/src/__tests__/package-config.test.ts | 18 +++- packages/core/package.json | 2 +- packages/dashboard/package.json | 2 +- packages/engine/package.json | 4 +- packages/pi-claude-cli/package.json | 8 +- .../__tests__/check-pi-versions-pinned.test.mjs | 45 ++++++++ scripts/check-pi-versions-pinned.mjs | 120 +++++++++++++++++++++ 11 files changed, 205 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-8201 Fusion-Task-Lineage: bf0ac363-df5f-4445-835b-cfd2d4909659 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8201-pin-pi-versions.md | 7 + docs/getting-started.md | 3 + package.json | 6 +- packages/cli/package.json | 4 +- .../cli/src/__tests__/package-config.test.ts | 18 ++- packages/core/package.json | 2 +- packages/dashboard/package.json | 2 +- packages/engine/package.json | 4 +- packages/pi-claude-cli/package.json | 8 +- .../check-pi-versions-pinned.test.mjs | 45 +++++++ scripts/check-pi-versions-pinned.mjs | 120 ++++++++++++++++++ 11 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-8201-pin-pi-versions.md create mode 100644 scripts/__tests__/check-pi-versions-pinned.test.mjs create mode 100644 scripts/check-pi-versions-pinned.mjs diff --git a/.changeset/fn-8201-pin-pi-versions.md b/.changeset/fn-8201-pin-pi-versions.md new file mode 100644 index 0000000000..f82b2518de --- /dev/null +++ b/.changeset/fn-8201-pin-pi-versions.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make global npm installs reliable by pinning the @earendil-works/pi-* version set. +category: fix +dev: Pins pi-ai and pi-coding-agent to exact 0.80.10 and adds check-pi-versions-pinned.mjs. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9db3ea52f3..32e1ce9c4c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,9 +41,12 @@ npm install -g @runfusion/fusion fn dashboard # or: fusion dashboard ``` +Fusion pins `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` as a locked version pair. If a global npm install ever reports a `pi-*` export mismatch, build from source with `pnpm install`; it honors this repository's committed lockfile and is the reliable fallback while upstream pi-mono patch exports stabilize. + ### From source (development) ```bash +pnpm install pnpm dev dashboard ``` diff --git a/package.json b/package.json index 3250b0baab..4fa96c7877 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,13 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", "check:line-count": "node scripts/check-file-line-count.mjs", "check:changesets": "node scripts/check-changeset-format.mjs", "check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs", "check:mock-completeness": "node scripts/check-mock-completeness.mjs", - "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape", + "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index 49c7320caf..2c371f2b62 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -60,8 +60,8 @@ "test:pre-release": "pnpm test:slow-cli && pnpm test:build-exe" }, "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-coding-agent": "^0.80.10", + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "dockerode": "^4.0.12", "electron": "^33.4.11", "embedded-postgres": "15.18.0-beta.17", diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index fc990738b5..4439f1836d 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -33,6 +33,14 @@ function hasProjectArg(script: string | undefined, project: string): boolean { return parts.some((part, index) => part === "--project" && parts[index + 1] === project); } +/* +FNXC:DependencyPinning 2026-07-17-12:00: +FN-8201 requires source and prepack-transformed manifests to keep pi-ai and +pi-coding-agent as one exact version pair, because npm global installation does +not honor pnpm-lock.yaml when resolving package dependency ranges. +*/ +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + function assertRuntimeDepsAreNotOptionalPeers(pkg: any, label: string): void { const dependencies = pkg.dependencies ?? {}; const peerDependencies = pkg.peerDependencies ?? {}; @@ -48,7 +56,10 @@ function assertRuntimeDepsAreNotOptionalPeers(pkg: any, label: string): void { for (const dependencyName of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai"]) { expect(dependencies, `${label}: ${dependencyName} must remain a required runtime dependency`).toHaveProperty( dependencyName, - "^0.80.6", + "0.80.10", + ); + expect(dependencies[dependencyName], `${label}: ${dependencyName} must be a clean exact semver`).toMatch( + EXACT_SEMVER, ); expect(peerDependencies, `${label}: ${dependencyName} must not be a peer dependency`).not.toHaveProperty( dependencyName, @@ -58,6 +69,11 @@ function assertRuntimeDepsAreNotOptionalPeers(pkg: any, label: string): void { ); } + expect( + dependencies["@earendil-works/pi-ai"], + `${label}: pi-ai and pi-coding-agent must remain a matched exact version pair`, + ).toBe(dependencies["@earendil-works/pi-coding-agent"]); + expect(dependencies, `${label}: typebox must not be promoted into runtime dependencies`).not.toHaveProperty( "typebox", ); diff --git a/packages/core/package.json b/packages/core/package.json index f1526ef4f4..946094086f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "test:pg-gate": "vitest run src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts src/__tests__/postgres/store-list.pg.test.ts src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts src/__tests__/postgres/soft-delete-resurrection-FN-5233.pg.test.ts src/__tests__/postgres/agent-logs-and-monitor.pg.test.ts src/__tests__/postgres/todo-store.pg.test.ts src/__tests__/postgres/workflow-definitions.pg.test.ts src/__tests__/postgres/message-store.pg.test.ts src/__tests__/postgres/insight-store.pg.test.ts src/__tests__/postgres/insight-run-execution.pg.test.ts src/__tests__/postgres/research-store.pg.test.ts src/__tests__/postgres/mission-store.pg.test.ts src/__tests__/postgres/goal-store.pg.test.ts src/__tests__/postgres/artifacts-documents-evals.pg.test.ts src/__tests__/postgres/command-center-analytics.pg.test.ts src/__tests__/postgres/command-center-remaining-analytics.pg.test.ts src/__tests__/postgres/research-execution.pg.test.ts src/__tests__/postgres/async-store-events.pg.test.ts src/__tests__/postgres/signal-ingestion.pg.test.ts src/__tests__/postgres/mission-autopilot.pg.test.ts src/__tests__/postgres/workflow-create.pg.test.ts src/__tests__/postgres/monitor-trait-storm-guard.pg.test.ts src/__tests__/postgres/agent-wake-getagent.pg.test.ts --silent=passed-only --reporter=dot" }, "devDependencies": { - "@earendil-works/pi-coding-agent": "^0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@types/dockerode": "^3.3.41", "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.0", diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 05928ed791..3ae36ac133 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -106,7 +106,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.4", - "@earendil-works/pi-coding-agent": "^0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@fusion-plugin-examples/cli-printing-press": "workspace:*", "@fusion-plugin-examples/compound-engineering": "workspace:*", "@fusion-plugin-examples/cursor-runtime": "workspace:*", diff --git a/packages/engine/package.json b/packages/engine/package.json index 5f0786fc4f..58ebbc5eb5 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -38,8 +38,8 @@ "test:watch": "vitest src/__tests__/executor-*.test.ts --watch" }, "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-coding-agent": "^0.80.10", + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@fusion/core": "workspace:*", "@fusion/pi-claude-cli": "workspace:*", "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index 6be3595241..85b7cd7ec4 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -23,12 +23,12 @@ "@agentclientprotocol/sdk": "0.24.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-coding-agent": "^0.80.10" + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-coding-agent": "^0.80.10", + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@types/node": "^22.0.0", "typescript": "^5.7.0", "vitest": "^4.1.0" diff --git a/scripts/__tests__/check-pi-versions-pinned.test.mjs b/scripts/__tests__/check-pi-versions-pinned.test.mjs new file mode 100644 index 0000000000..c7f6506818 --- /dev/null +++ b/scripts/__tests__/check-pi-versions-pinned.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + scanTrackedManifests, + validateManifestSet, +} from "../check-pi-versions-pinned.mjs"; + +const pinnedManifest = { + dependencies: { + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", + }, +}; + +function validate(manifest) { + return validateManifestSet([{ filePath: "packages/cli/package.json", manifest }]); +} + +describe("check-pi-versions-pinned", () => { + it("passes against every guarded repository manifest", () => { + assert.deepEqual(scanTrackedManifests(), []); + }); + + it("rejects caret, tilde, wildcard, x, and comparator ranges", () => { + for (const version of ["^0.80.10", "~0.80.10", "*", "0.80.x", ">=0.80.10"]) { + const violations = validate({ + ...pinnedManifest, + dependencies: { ...pinnedManifest.dependencies, "@earendil-works/pi-ai": version }, + }); + assert.equal(violations.length > 0, true, `${version} must be rejected`); + } + }); + + it("rejects a matched-set version mismatch", () => { + const violations = validate({ + ...pinnedManifest, + dependencies: { ...pinnedManifest.dependencies, "@earendil-works/pi-coding-agent": "0.80.11" }, + }); + assert.equal(violations.some((violation) => violation.includes("same exact version")), true); + }); + + it("accepts a clean exact matched pair", () => { + assert.deepEqual(validate(pinnedManifest), []); + }); +}); diff --git a/scripts/check-pi-versions-pinned.mjs b/scripts/check-pi-versions-pinned.mjs new file mode 100644 index 0000000000..2e110d1ce8 --- /dev/null +++ b/scripts/check-pi-versions-pinned.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* +FNXC:DependencyPinning 2026-07-17-12:00: +FN-8201 / Runfusion/Fusion#2270 requires the pi-ai and pi-coding-agent runtime +set to remain version-locked. npm global installs ignore pnpm-lock.yaml and can +independently resolve ranges to incompatible pi-mono patches, so every guarded +manifest declaration must be an exact semver and all declarations must agree. +*/ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export const PI_DEPENDENCIES = [ + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", +]; + +export const GUARDED_MANIFESTS = [ + "packages/cli/package.json", + "packages/core/package.json", + "packages/engine/package.json", + "packages/dashboard/package.json", + "packages/pi-claude-cli/package.json", +]; + +const DEPENDENCY_BLOCKS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export function isExactSemver(version) { + return typeof version === "string" && EXACT_SEMVER.test(version); +} + +function listTrackedManifests() { + const result = spawnSync("git", ["ls-files", "--", ...GUARDED_MANIFESTS], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) throw new Error(result.stderr?.trim() || "git ls-files failed"); + return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean); +} + +export function validateManifestSet(manifests) { + const violations = []; + const declaredVersions = new Map(); + + for (const { filePath, manifest } of manifests) { + for (const blockName of DEPENDENCY_BLOCKS) { + const dependencies = manifest?.[blockName]; + if (!dependencies || typeof dependencies !== "object") continue; + + for (const packageName of PI_DEPENDENCIES) { + if (!(packageName in dependencies)) continue; + const version = dependencies[packageName]; + if (!isExactSemver(version)) { + violations.push(`${filePath}: ${blockName}.${packageName} must be an exact semver, found ${JSON.stringify(version)}`); + continue; + } + const declarations = declaredVersions.get(packageName) ?? []; + declarations.push({ filePath, blockName, version }); + declaredVersions.set(packageName, declarations); + } + } + } + + for (const packageName of PI_DEPENDENCIES) { + const declarations = declaredVersions.get(packageName) ?? []; + const versions = [...new Set(declarations.map(({ version }) => version))]; + if (versions.length > 1) { + violations.push(`${packageName} must use one exact version across guarded manifests; found ${versions.join(", ")} (${declarations.map(({ filePath, blockName, version }) => `${filePath}:${blockName}=${version}`).join(", ")})`); + } + } + + const allVersions = [...new Set( + PI_DEPENDENCIES.flatMap((packageName) => + (declaredVersions.get(packageName) ?? []).map(({ version }) => version), + ), + )]; + if (allVersions.length > 1) { + violations.push(`${PI_DEPENDENCIES.join(" and ")} must resolve to the same exact version; found ${allVersions.join(", ")}`); + } + + return violations; +} + +export function scanTrackedManifests(files = listTrackedManifests(), options = {}) { + const readFile = options.readFile ?? readFileSync; + const manifests = []; + for (const filePath of files) { + let source; + try { + source = readFile(filePath, "utf8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue; + throw error; + } + try { + manifests.push({ filePath, manifest: JSON.parse(source) }); + } catch (error) { + return [`${filePath}: invalid JSON (${error instanceof Error ? error.message : String(error)})`]; + } + } + return validateManifestSet(manifests); +} + +export function formatFailureMessage(violations) { + return [ + "[check-pi-versions-pinned] pi runtime dependencies must be exact, matched versions.", + "npm global installs do not use pnpm-lock.yaml; ranges can resolve an incompatible pi-mono patch set.", + ...violations.map((violation) => `- ${violation}`), + ].join("\n"); +} + +export function main() { + const violations = scanTrackedManifests(); + if (!violations.length) return 0; + console.error(formatFailureMessage(violations)); + return 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();