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:
@@ -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];
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user