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<S,E> signature
- Update E2E T5 to seed vitals data (tick no longer writes collect events)
- Update E2E T2/T4 test patterns for new Rule<S,E> signature
This commit is contained in:
2026-04-14 17:10:45 +00:00
parent 20aa456b5f
commit 6e54c2ab2d
5 changed files with 173 additions and 93 deletions
+79 -45
View File
@@ -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
@@ -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<Snapshot, Effect> = (_prev, _curr) => (effects, tickMs) => {
const rule: Rule<Snapshot, Effect> = async (_prev, _curr, inner) => {
const [effects, tickMs] = await inner(_prev, _curr);
return [[...effects, { kind: 'log', message: 'hello from v2' }], tickMs];
};
+1 -1
View File
@@ -57,7 +57,7 @@ function addRuleToStaging(
import type { Snapshot, Effect } from '../types.js';
/** Auto-generated test rule: ${filename} */
const rule: Rule<Snapshot, Effect> = (_prev, _curr) => (effects, tickMs) => [effects, tickMs];
const rule: Rule<Snapshot, Effect> = async (_prev, _curr, inner) => inner(_prev, _curr);
export default rule;
`,
'utf-8',
+40 -21
View File
@@ -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<Snapshot, Effect, number>(
(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<Snapshot, Effect> = 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,
+51 -25
View File
@@ -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<Snapshot, Effect>();
}
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<Snapshot, Effect, number>(
(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<Snapshot, Effect> = 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'),