diff --git a/docs/VERSION_FIX.md b/docs/VERSION_FIX.md new file mode 100644 index 00000000..75732ac6 --- /dev/null +++ b/docs/VERSION_FIX.md @@ -0,0 +1,79 @@ +# Version Consistency Fix (Issue #XXX) + +## Problem +Version mismatch between plugin and worker caused infinite restart loop: +- Plugin version: 9.0.0 (from plugin/.claude-plugin/plugin.json) +- Worker binary version: 8.5.9 (hardcoded in bundled worker-service.cjs) + +This triggered the auto-restart mechanism on every hook call, which killed the SDK generator before it could complete the Claude API call to generate observations. Result: 0 observations were ever saved to the database despite hooks firing successfully. + +## Root Cause +The `plugin/package.json` file had version `8.5.10` instead of `9.0.0`. When the project was last built, the build script correctly injected the version from root `package.json` into the bundled worker service. However, the `plugin/package.json` was manually created/edited and fell out of sync. + +At runtime: +1. Worker service reads version from `~/.claude/plugins/marketplaces/thedotmack/package.json` → gets `8.5.10` +2. Running worker returns built-in version via `/api/version` → returns `8.5.9` (from old build) +3. Version check in `worker-service.ts` start command detects mismatch +4. Auto-restart triggered on every hook call +5. Observations never saved + +## Solution +1. Updated `plugin/package.json` from version `8.5.10` to `9.0.0` +2. Rebuilt all hooks and worker service to inject correct version (`9.0.0`) into bundled artifacts +3. Added comprehensive test suite to prevent future version mismatches + +## Verification +All versions now match: +``` +Root package.json: 9.0.0 ✓ +plugin/package.json: 9.0.0 ✓ +plugin.json: 9.0.0 ✓ +marketplace.json: 9.0.0 ✓ +worker-service.cjs: 9.0.0 ✓ +``` + +## Prevention +To prevent this issue in the future: + +1. **Automated Build Process**: The `scripts/build-hooks.js` now regenerates `plugin/package.json` automatically with the correct version from root `package.json` + +2. **Version Consistency Tests**: Added `tests/infrastructure/version-consistency.test.ts` to verify all version sources match + +3. **Version Management Best Practices**: + - NEVER manually edit `plugin/package.json` - it's auto-generated during build + - Always update version in root `package.json` only + - Run `npm run build` after version changes + - The build script will sync the version to all necessary locations + +## Files Changed +- `plugin/package.json` - Updated version from 8.5.10 to 9.0.0 +- `plugin/scripts/worker-service.cjs` - Rebuilt with version 9.0.0 injected +- `plugin/scripts/mcp-server.cjs` - Rebuilt with version 9.0.0 injected +- `plugin/scripts/*.js` (hooks) - Rebuilt with version 9.0.0 injected +- `tests/infrastructure/version-consistency.test.ts` - New test suite + +## Testing +Run the version consistency test: +```bash +npm run test:infra +``` + +Or manually verify: +```bash +node -e " +const fs = require('fs'); +const rootPkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); +const pluginPkg = JSON.parse(fs.readFileSync('plugin/package.json', 'utf-8')); +const workerContent = fs.readFileSync('plugin/scripts/worker-service.cjs', 'utf-8'); +const workerMatch = workerContent.match(/Bre=\"([0-9.]+)\"/); +console.log('Root:', rootPkg.version); +console.log('Plugin:', pluginPkg.version); +console.log('Worker:', workerMatch ? workerMatch[1] : 'NOT_FOUND'); +" +``` + +## Related Code Locations +- **Version Injection**: `scripts/build-hooks.js` line 43-45, 105, 130, 155, 178 +- **Version Check**: `src/services/infrastructure/HealthMonitor.ts` line 133-143 +- **Auto-Restart Logic**: `src/services/worker-service.ts` line 627-645 +- **Runtime Version Read**: `src/shared/worker-utils.ts` line 73-76, 82-91 diff --git a/tests/infrastructure/version-consistency.test.ts b/tests/infrastructure/version-consistency.test.ts new file mode 100644 index 00000000..4ba7006f --- /dev/null +++ b/tests/infrastructure/version-consistency.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'bun:test'; +import { readFileSync, existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '../..'); + +/** + * Test suite to ensure version consistency across all package.json files + * and built artifacts. + * + * This prevents the infinite restart loop issue where: + * - Plugin reads version from plugin/package.json + * - Worker returns built-in version from bundled code + * - Mismatch triggers restart on every hook call + */ +describe('Version Consistency', () => { + let rootVersion: string; + + it('should read version from root package.json', () => { + const packageJsonPath = path.join(projectRoot, 'package.json'); + expect(existsSync(packageJsonPath)).toBe(true); + + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + expect(packageJson.version).toBeDefined(); + expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/); + + rootVersion = packageJson.version; + }); + + it('should have matching version in plugin/package.json', () => { + const pluginPackageJsonPath = path.join(projectRoot, 'plugin/package.json'); + expect(existsSync(pluginPackageJsonPath)).toBe(true); + + const pluginPackageJson = JSON.parse(readFileSync(pluginPackageJsonPath, 'utf-8')); + expect(pluginPackageJson.version).toBe(rootVersion); + }); + + it('should have matching version in plugin/.claude-plugin/plugin.json', () => { + const pluginJsonPath = path.join(projectRoot, 'plugin/.claude-plugin/plugin.json'); + expect(existsSync(pluginJsonPath)).toBe(true); + + const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8')); + expect(pluginJson.version).toBe(rootVersion); + }); + + it('should have matching version in .claude-plugin/marketplace.json', () => { + const marketplaceJsonPath = path.join(projectRoot, '.claude-plugin/marketplace.json'); + expect(existsSync(marketplaceJsonPath)).toBe(true); + + const marketplaceJson = JSON.parse(readFileSync(marketplaceJsonPath, 'utf-8')); + expect(marketplaceJson.plugins).toBeDefined(); + expect(marketplaceJson.plugins.length).toBeGreaterThan(0); + + const claudeMemPlugin = marketplaceJson.plugins.find((p: any) => p.name === 'claude-mem'); + expect(claudeMemPlugin).toBeDefined(); + expect(claudeMemPlugin.version).toBe(rootVersion); + }); + + it('should have version injected into built worker-service.cjs', () => { + const workerServicePath = path.join(projectRoot, 'plugin/scripts/worker-service.cjs'); + + // Skip if file doesn't exist (e.g., before first build) + if (!existsSync(workerServicePath)) { + console.log('⚠️ worker-service.cjs not found - run npm run build first'); + return; + } + + const workerServiceContent = readFileSync(workerServicePath, 'utf-8'); + + // The build script injects version via esbuild define: + // define: { '__DEFAULT_PACKAGE_VERSION__': `"${version}"` } + // This becomes: const BUILT_IN_VERSION = "9.0.0" (or minified: Bre="9.0.0") + + // Check for the version string in the minified code + const versionPattern = new RegExp(`"${rootVersion.replace(/\./g, '\\.')}"`, 'g'); + const matches = workerServiceContent.match(versionPattern); + + expect(matches).toBeTruthy(); + expect(matches!.length).toBeGreaterThan(0); + }); + + it('should have version injected into built mcp-server.cjs', () => { + const mcpServerPath = path.join(projectRoot, 'plugin/scripts/mcp-server.cjs'); + + // Skip if file doesn't exist (e.g., before first build) + if (!existsSync(mcpServerPath)) { + console.log('⚠️ mcp-server.cjs not found - run npm run build first'); + return; + } + + const mcpServerContent = readFileSync(mcpServerPath, 'utf-8'); + + // Check for the version string in the minified code + const versionPattern = new RegExp(`"${rootVersion.replace(/\./g, '\\.')}"`, 'g'); + const matches = mcpServerContent.match(versionPattern); + + expect(matches).toBeTruthy(); + expect(matches!.length).toBeGreaterThan(0); + }); + + it('should validate version format is semver compliant', () => { + // Ensure version follows semantic versioning: MAJOR.MINOR.PATCH + expect(rootVersion).toMatch(/^\d+\.\d+\.\d+$/); + + const [major, minor, patch] = rootVersion.split('.').map(Number); + expect(major).toBeGreaterThanOrEqual(0); + expect(minor).toBeGreaterThanOrEqual(0); + expect(patch).toBeGreaterThanOrEqual(0); + }); +}); + +/** + * Additional test to ensure build script properly reads and injects version + */ +describe('Build Script Version Handling', () => { + it('should read version from package.json in build-hooks.js', () => { + const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js'); + expect(existsSync(buildScriptPath)).toBe(true); + + const buildScriptContent = readFileSync(buildScriptPath, 'utf-8'); + + // Verify build script reads from package.json + expect(buildScriptContent).toContain("readFileSync('package.json'"); + expect(buildScriptContent).toContain('packageJson.version'); + + // Verify it generates plugin/package.json with the version + expect(buildScriptContent).toContain('version: version'); + + // Verify it injects version into esbuild define + expect(buildScriptContent).toContain('__DEFAULT_PACKAGE_VERSION__'); + expect(buildScriptContent).toContain('`"${version}"`'); + }); +});