From 6e54c2ab2d942a3fd45b53561cd51a7b02dc2f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Tue, 14 Apr 2026 17:10:45 +0000 Subject: [PATCH] fix: complete engine/staging project isolation (#44) - Remove symlink: engine/ and staging/ have independent node_modules - Init: bun install in engine before staging worktree (bun.lock committed) - Init: symlink migration detection (auto-replace legacy symlinks) - Promote: graceful 'bun test' when staging has no test files - Promote: frozen-lockfile with fallback to regular bun install - Promote: migrate discovers sense keys from vitals (post-#47 compat) - Rollback: frozen-lockfile with fallback to regular bun install - Fix createRule template to use correct 3-param Rule signature - Update E2E T5 to seed vitals data (tick no longer writes collect events) - Update E2E T2/T4 test patterns for new Rule signature --- packages/upulse/src/commands/deploy.ts | 124 +++++++++++------- .../upulse/src/e2e/t2-staging-promote.test.ts | 3 +- packages/upulse/src/e2e/t4-rollback.test.ts | 2 +- packages/upulse/src/e2e/t5-migrate.test.ts | 61 ++++++--- packages/upulse/src/init.ts | 76 +++++++---- 5 files changed, 173 insertions(+), 93 deletions(-) diff --git a/packages/upulse/src/commands/deploy.ts b/packages/upulse/src/commands/deploy.ts index 9e7214d..a1a06e6 100644 --- a/packages/upulse/src/commands/deploy.ts +++ b/packages/upulse/src/commands/deploy.ts @@ -90,11 +90,27 @@ async function tryWriteMigrateEvents( '@uncaged/pulse' ); const epoch = findEffectiveEpoch(store); - // Discover sense keys from existing collect events - const collects = store.queryByKind('collect', { limit: 100 }); - const senseKeys = [ - ...new Set(collects.map((e) => e.key).filter(Boolean) as string[]), - ]; + // Discover sense keys from vitals table first, then fall back to collect events + let senseKeys: string[] = []; + try { + // @ts-expect-error — accessing raw vitalsDb for key discovery + const vitalsDb = (store as any)._vitalsDb ?? (store as any).vitalsDb; + if (vitalsDb) { + const rows = vitalsDb + .prepare('SELECT DISTINCT key FROM vitals') + .all() as Array<{ key: string }>; + senseKeys = rows.map((r: { key: string }) => r.key); + } + } catch { + /* vitalsDb not accessible */ + } + if (senseKeys.length === 0) { + // Fallback: discover from collect events (pre-#47 stores) + const collects = store.queryByKind('collect', { limit: 100 }); + senseKeys = [ + ...new Set(collects.map((e) => e.key).filter(Boolean) as string[]), + ]; + } const currentSnapshot = rebuildSnapshot(store, senseKeys, epoch); const migrated = migrateFn(currentSnapshot); @@ -180,12 +196,21 @@ export function registerDeployCommand(program: Command): void { try { execSync('bun test', { cwd: config.staging.path, - stdio: 'inherit', + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', }); console.log(' ✓ staging tests pass'); - } catch { - console.error('Error: staging tests failed. Aborting promote.'); - process.exit(1); + } catch (testErr: unknown) { + const errObj = testErr as { stderr?: string; stdout?: string }; + const stderr = errObj.stderr ?? ''; + // "No tests found" is OK — staging may not have test files + if (stderr.includes('No tests found')) { + console.log(' ✓ no tests in staging (skipped)'); + } else { + console.error('Error: staging tests failed. Aborting promote.'); + if (stderr) console.error(stderr); + process.exit(1); + } } // Note staging source path before merge (Bun runs .ts directly) @@ -224,20 +249,26 @@ export function registerDeployCommand(program: Command): void { process.exit(1); } - // Step 5: Run bun install --frozen-lockfile after merge (atomic dependency update) - console.log( - 'Step 5: Running bun install --frozen-lockfile after merge...', - ); + // Step 5: Run bun install after merge (picks up new deps) + console.log('Step 5: Running bun install after merge...'); try { - execSync('bun install --frozen-lockfile', { - cwd: config.engine.path, - stdio: 'inherit', - }); - console.log(' ✓ bun install --frozen-lockfile complete'); + // Try --frozen-lockfile first (atomic, reproducible) + try { + execSync('bun install --frozen-lockfile', { + cwd: config.engine.path, + stdio: ['pipe', 'pipe', 'pipe'], + }); + console.log(' ✓ bun install --frozen-lockfile complete'); + } catch { + // Fallback: no lockfile or deps changed — regular install + execSync('bun install', { + cwd: config.engine.path, + stdio: 'inherit', + }); + console.log(' ✓ bun install complete (lockfile updated)'); + } } catch { - console.error( - 'Error: bun install --frozen-lockfile failed after merge. Rolling back...', - ); + console.error('Error: bun install failed after merge. Rolling back...'); // Rollback: reset engine to pre-merge state try { gitResetHard(config.engine.path, 'HEAD~1'); @@ -254,7 +285,7 @@ export function registerDeployCommand(program: Command): void { kind: 'error', meta: JSON.stringify({ type: 'promote_rollback', - reason: 'bun_install_frozen_lockfile_failed', + reason: 'bun_install_failed', }), }); errorStore.close(); @@ -428,27 +459,29 @@ export function registerDeployCommand(program: Command): void { process.exit(1); } - // Step 5: Run bun install --frozen-lockfile after revert - console.log( - 'Step 5: Running bun install --frozen-lockfile after revert...', - ); + // Step 5: Run bun install after revert + console.log('Step 5: Running bun install after revert...'); try { - execSync('bun install --frozen-lockfile', { - cwd: config.engine.path, - stdio: 'inherit', - }); - console.log(' ✓ bun install --frozen-lockfile complete'); - } catch { - console.warn( - 'Warning: bun install --frozen-lockfile failed. Falling back to regular bun install...', - ); + // Try --frozen-lockfile first (atomic, reproducible) try { - execSync('bun install --no-cache', { + execSync('bun install --frozen-lockfile', { + cwd: config.engine.path, + stdio: ['pipe', 'pipe', 'pipe'], + }); + console.log(' ✓ bun install --frozen-lockfile complete'); + } catch { + execSync('bun install', { cwd: config.engine.path, stdio: 'inherit', }); - console.log(' ✓ fallback bun install complete'); - // Write warning event for monitoring + console.log(' ✓ bun install complete (lockfile updated)'); + } + } catch (installErr: unknown) { + console.error( + `Error: bun install failed after revert: ${installErr instanceof Error ? installErr.message : String(installErr)}`, + ); + // Write warning event for monitoring + try { const warningStore = openOrCreateStore( config.store.eventsDbFile, config.store.vitalsDbFile, @@ -457,17 +490,18 @@ export function registerDeployCommand(program: Command): void { occurredAt: Date.now(), kind: 'warn', meta: JSON.stringify({ - type: 'rollback_fallback', - reason: 'frozen_lockfile_failed', + type: 'rollback_install_failed', + reason: + installErr instanceof Error + ? installErr.message + : String(installErr), }), }); warningStore.close(); - } catch (fallbackErr: unknown) { - console.error( - `Error: fallback bun install also failed: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`, - ); - process.exit(1); + } catch { + /* best-effort */ } + process.exit(1); } // Step 6: Type-check engine diff --git a/packages/upulse/src/e2e/t2-staging-promote.test.ts b/packages/upulse/src/e2e/t2-staging-promote.test.ts index e3cc43d..d420cbe 100644 --- a/packages/upulse/src/e2e/t2-staging-promote.test.ts +++ b/packages/upulse/src/e2e/t2-staging-promote.test.ts @@ -46,7 +46,8 @@ describe('E2E T2: Staging → Promote', () => { const newRuleContent = `import type { Snapshot, Effect } from '../types.js'; import type { Rule } from '@uncaged/pulse'; -const rule: Rule = (_prev, _curr) => (effects, tickMs) => { +const rule: Rule = async (_prev, _curr, inner) => { + const [effects, tickMs] = await inner(_prev, _curr); return [[...effects, { kind: 'log', message: 'hello from v2' }], tickMs]; }; diff --git a/packages/upulse/src/e2e/t4-rollback.test.ts b/packages/upulse/src/e2e/t4-rollback.test.ts index b3116aa..35fb2fb 100644 --- a/packages/upulse/src/e2e/t4-rollback.test.ts +++ b/packages/upulse/src/e2e/t4-rollback.test.ts @@ -57,7 +57,7 @@ function addRuleToStaging( import type { Snapshot, Effect } from '../types.js'; /** Auto-generated test rule: ${filename} */ -const rule: Rule = (_prev, _curr) => (effects, tickMs) => [effects, tickMs]; +const rule: Rule = async (_prev, _curr, inner) => inner(_prev, _curr); export default rule; `, 'utf-8', diff --git a/packages/upulse/src/e2e/t5-migrate.test.ts b/packages/upulse/src/e2e/t5-migrate.test.ts index 8479f91..a2d2dea 100644 --- a/packages/upulse/src/e2e/t5-migrate.test.ts +++ b/packages/upulse/src/e2e/t5-migrate.test.ts @@ -50,16 +50,33 @@ describe('E2E T5: Migrate Chain Migration', () => { output.includes('Snapshot') || output.includes('Result'), ).toBeTruthy(); - // Verify v1 collect events exist - const collectEvents = queryEventsDb( - ctx, - "SELECT * FROM events WHERE kind = 'collect' AND key = 'system'", - ) as Array<{ - kind: string; - key: string; - hash: string; - }>; - expect(collectEvents.length > 0).toBeTruthy(); + // Seed v1 system data into vitals + objects (tick no longer collects after #47) + const v1Data = { memoryPct: 42.5, cpuIdlePct: 87.3 }; + const v1Hash = require('node:crypto') + .createHash('sha256') + .update(JSON.stringify(v1Data)) + .digest('hex') + .slice(0, 32); + const { mkdirSync, writeFileSync: wfs } = require('node:fs'); + mkdirSync(ctx.objectsDir, { recursive: true }); + wfs(join(ctx.objectsDir, `${v1Hash}.json`), JSON.stringify(v1Data)); + + // Insert vitals record + const Database = require('bun:sqlite').default; + const vitalsDb = new Database(join(ctx.upulseDir, 'vitals.db')); + vitalsDb.exec(` + INSERT INTO vitals (id, occurred_at, key, hash, meta) + VALUES ('test-vital-01', ${Date.now()}, 'system', '${v1Hash}', '{}') + `); + vitalsDb.close(); + + // Also insert collect event for backward-compat sense key discovery + const eventsDb = new Database(join(ctx.upulseDir, 'events.db')); + eventsDb.exec(` + INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta) + VALUES ('test-collect-01', ${Date.now()}, 'collect', 'system', '${v1Hash}', '', '{}') + `); + eventsDb.close(); }); it('modify types.ts to v2 format in staging', () => { @@ -126,23 +143,25 @@ export type Effect = }); it('update 01-collect-system.ts to adapt new format in staging', () => { - const newSystemRuleContent = `import { createRule } from '@uncaged/pulse'; -import type { Snapshot, Effect } from '../types.js'; + const newSystemRuleContent = `import type { Snapshot, Effect } from '../types.js'; +import type { Rule } from '@uncaged/pulse'; /** * Trigger system collection every 15 seconds. * If system sense is stale (refreshedAt > 15s ago), emit collect effect. * Updated for v2 format. */ -export default createRule( - (s) => s.system?.refreshedAt ?? 0, - (prev, curr) => (effects, tickMs) => { - if (Date.now() - curr > 15000) { - return [[...effects, { kind: 'collect', key: 'system' }], tickMs]; - } - return [effects, tickMs]; - }, -);`; +const collectSystemRule: Rule = async (_prev, curr, inner) => { + const [effects, tickMs] = await inner(_prev, curr); + const refreshedAt = curr.system?.refreshedAt ?? 0; + if (Date.now() - refreshedAt > 15000) { + return [[...effects, { kind: 'collect', key: 'system' }], tickMs]; + } + return [effects, tickMs]; +}; + +export default collectSystemRule; +`; const systemRuleFilePath = join( ctx.stagingDir, diff --git a/packages/upulse/src/init.ts b/packages/upulse/src/init.ts index 54ff5e5..5a23c3d 100644 --- a/packages/upulse/src/init.ts +++ b/packages/upulse/src/init.ts @@ -5,7 +5,13 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + existsSync, + lstatSync, + mkdirSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { defaultConfig, saveConfig } from './config.js'; @@ -73,11 +79,7 @@ export function initUpulse(dir?: string): void { gitCommit(config.engine.path, 'init: pulse engine scaffold'); console.log(' ✓ git init + initial commit'); - // Create staging worktree - gitWorktreeAdd(config.engine.path, config.staging.path, 'staging'); - console.log(' ✓ staging worktree created'); - - // bun install in engine + // bun install in engine (before staging worktree so bun.lock is committed) console.log('\nInstalling dependencies...'); try { execSync('bun install', { cwd: config.engine.path, stdio: 'inherit' }); @@ -88,16 +90,39 @@ export function initUpulse(dir?: string): void { ); } - // Symlink node_modules to staging (staging shares engine's deps) + // Commit bun.lock so staging worktree gets it + gitAddAll(config.engine.path); try { - symlinkSync( - join(config.engine.path, 'node_modules'), - join(config.staging.path, 'node_modules'), - 'dir', - ); - console.log(' ✓ node_modules symlinked to staging'); + gitCommit(config.engine.path, 'chore: add bun.lock'); } catch { - console.error('Warning: failed to symlink node_modules to staging'); + // No changes to commit (bun.lock might not exist) + } + + // Create staging worktree (after bun.lock is committed) + gitWorktreeAdd(config.engine.path, config.staging.path, 'staging'); + console.log(' ✓ staging worktree created'); + + // Install dependencies in staging (independent node_modules) + // Symlink migration: if staging/node_modules is a symlink from old init, remove it first + const stagingNodeModules = join(config.staging.path, 'node_modules'); + try { + if ( + existsSync(stagingNodeModules) && + lstatSync(stagingNodeModules).isSymbolicLink() + ) { + unlinkSync(stagingNodeModules); + console.log(' ✓ removed legacy node_modules symlink'); + } + } catch { + // best-effort migration + } + try { + execSync('bun install', { cwd: config.staging.path, stdio: 'inherit' }); + console.log(' ✓ staging bun install complete'); + } catch { + console.error( + 'Warning: staging bun install failed. Run manually: cd staging && bun install', + ); } console.log('\n✅ Pulse engine initialized!'); @@ -327,22 +352,23 @@ export default clampTick(); } function writeRuleCollectSystem(enginePath: string): void { - const content = `import { createRule } from '@uncaged/pulse'; -import type { Snapshot, Effect } from '../types.js'; + const content = `import type { Snapshot, Effect } from '../types.js'; +import type { Rule } from '@uncaged/pulse'; /** * Trigger system collection every 15 seconds. * If system sense is stale (refreshedAt > 15s ago), emit collect effect. */ -export default createRule( - (s) => s.system?.refreshedAt ?? 0, - (prev, curr) => (effects, tickMs) => { - if (Date.now() - curr > 15000) { - return [[...effects, { kind: 'collect', key: 'system' }], tickMs]; - } - return [effects, tickMs]; - }, -); +const collectSystemRule: Rule = async (_prev, curr, inner) => { + const [effects, tickMs] = await inner(_prev, curr); + const refreshedAt = curr.system?.refreshedAt ?? 0; + if (Date.now() - refreshedAt > 15000) { + return [[...effects, { kind: 'collect', key: 'system' }], tickMs]; + } + return [effects, tickMs]; +}; + +export default collectSystemRule; `; writeFileSync( join(enginePath, 'rules', '01-collect-system.ts'),