chore: add Biome linter + format all code + CI lint step (#26)
- biome.json: 2-space indent, single quotes, recommended rules - All 30 source files formatted (auto-fix, no logic changes) - CI: Biome lint runs before type check and tests - Root package.json with lint/lint:fix scripts 89 unit tests green (82 core + 7 watcher). 小橘 🍊(NEKO Team) Co-authored-by: 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -16,6 +16,12 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Biome
|
||||
run: bun install
|
||||
|
||||
- name: Lint (Biome)
|
||||
run: bunx biome check packages/
|
||||
|
||||
- name: Install dependencies (pulse)
|
||||
working-directory: packages/pulse
|
||||
run: bun install
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["packages/**/*.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "biome check packages/",
|
||||
"lint:fix": "biome check --write packages/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.11"
|
||||
}
|
||||
}
|
||||
+484
-220
File diff suppressed because it is too large
Load Diff
+47
-14
@@ -12,7 +12,7 @@
|
||||
* Composition: pulse = reduceRight rules base
|
||||
*/
|
||||
|
||||
import type { PulseStore, EventRecord } from './store.js';
|
||||
import type { EventRecord, PulseStore } from './store.js';
|
||||
import { startWatcher, type WatcherDef } from './watcher.js';
|
||||
|
||||
// ── Sensed Types ───────────────────────────────────────────────
|
||||
@@ -71,12 +71,20 @@ export function composeRules<S, E>(
|
||||
defaultTickMs: number = 15000,
|
||||
): (prev: S, curr: S) => Promise<[E[], number]> {
|
||||
const chain = rules.reduceRight<
|
||||
(prev: S, curr: S, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>
|
||||
(
|
||||
prev: S,
|
||||
curr: S,
|
||||
inner: (prev: S, curr: S) => Promise<[E[], number]>,
|
||||
) => Promise<[E[], number]>
|
||||
>(
|
||||
(next, rule) => (prev, curr, inner) => rule(prev, curr, (p, c) => next(p, c, inner)),
|
||||
(next, rule) => (prev, curr, inner) =>
|
||||
rule(prev, curr, (p, c) => next(p, c, inner)),
|
||||
(prev, curr, inner) => inner(prev, curr),
|
||||
);
|
||||
const base: (prev: S, curr: S) => Promise<[E[], number]> = async () => [[], defaultTickMs];
|
||||
const base: (prev: S, curr: S) => Promise<[E[], number]> = async () => [
|
||||
[],
|
||||
defaultTickMs,
|
||||
];
|
||||
return (prev, curr) => chain(prev, curr, base);
|
||||
}
|
||||
|
||||
@@ -135,11 +143,15 @@ export function rebuildSnapshot<S extends { timestamp: number }>(
|
||||
key,
|
||||
codeRev: epoch.codeRev ?? undefined,
|
||||
});
|
||||
const latestCollect = events.length > 0 ? events[events.length - 1]! : null;
|
||||
const latestCollect =
|
||||
events.length > 0 ? events[events.length - 1]! : null;
|
||||
if (latestCollect?.hash) {
|
||||
const data = store.getObject(latestCollect.hash);
|
||||
if (data !== null) {
|
||||
snapshot[key] = { data, refreshedAt: latestCollect.occurredAt } as Sensed<unknown>;
|
||||
snapshot[key] = {
|
||||
data,
|
||||
refreshedAt: latestCollect.occurredAt,
|
||||
} as Sensed<unknown>;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +167,10 @@ export function rebuildSnapshot<S extends { timestamp: number }>(
|
||||
if (latestMigrate.hash) {
|
||||
const data = store.getObject(latestMigrate.hash);
|
||||
if (data !== null) {
|
||||
snapshot[key] = { data, refreshedAt: latestMigrate.occurredAt } as Sensed<unknown>;
|
||||
snapshot[key] = {
|
||||
data,
|
||||
refreshedAt: latestMigrate.occurredAt,
|
||||
} as Sensed<unknown>;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -172,7 +187,10 @@ export function rebuildSnapshot<S extends { timestamp: number }>(
|
||||
if (latestInit.hash) {
|
||||
const data = store.getObject(latestInit.hash);
|
||||
if (data !== null) {
|
||||
snapshot[key] = { data, refreshedAt: latestInit.occurredAt } as Sensed<unknown>;
|
||||
snapshot[key] = {
|
||||
data,
|
||||
refreshedAt: latestInit.occurredAt,
|
||||
} as Sensed<unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +200,10 @@ export function rebuildSnapshot<S extends { timestamp: number }>(
|
||||
if (latest?.hash) {
|
||||
const data = store.getObject(latest.hash);
|
||||
if (data !== null) {
|
||||
snapshot[key] = { data, refreshedAt: latest.occurredAt } as Sensed<unknown>;
|
||||
snapshot[key] = {
|
||||
data,
|
||||
refreshedAt: latest.occurredAt,
|
||||
} as Sensed<unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +232,14 @@ export async function runPulse<S extends { timestamp: number }, E>(options: {
|
||||
codeRev?: string;
|
||||
watchers?: WatcherDef[];
|
||||
}): Promise<never> {
|
||||
const { store, execute, rules, senseKeys, defaultTickMs = 15000, codeRev } = options;
|
||||
const {
|
||||
store,
|
||||
execute,
|
||||
rules,
|
||||
senseKeys,
|
||||
defaultTickMs = 15000,
|
||||
codeRev,
|
||||
} = options;
|
||||
const pulse = composeRules(rules, defaultTickMs);
|
||||
|
||||
// Determine version epoch
|
||||
@@ -316,17 +344,22 @@ export function createRule<S, E, T>(
|
||||
// ── Storage ────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
createStore,
|
||||
type CreateStoreOptions,
|
||||
type PulseStore,
|
||||
createStore,
|
||||
type EventRecord,
|
||||
type PulseStore,
|
||||
type VitalRecord,
|
||||
} from './store.js';
|
||||
|
||||
// ── Built-in Rules ─────────────────────────────────────────────
|
||||
|
||||
export { clampTick, errorBackoff, adaptiveInterval, dedup } from './rules.js';
|
||||
export { adaptiveInterval, clampTick, dedup, errorBackoff } from './rules.js';
|
||||
|
||||
// ── Watcher ────────────────────────────────────────────────────
|
||||
|
||||
export { startWatcher, type WatcherDef, type WatcherHandle, type WakeCondition } from './watcher.js';
|
||||
export {
|
||||
startWatcher,
|
||||
type WakeCondition,
|
||||
type WatcherDef,
|
||||
type WatcherHandle,
|
||||
} from './watcher.js';
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Tests for @uncaged/pulse built-in rules
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { clampTick, errorBackoff, adaptiveInterval, dedup } from './rules.js';
|
||||
import { adaptiveInterval, clampTick, dedup, errorBackoff } from './rules.js';
|
||||
|
||||
// Helper to apply rule with new onion middleware signature
|
||||
async function apply<S, E>(
|
||||
@@ -61,7 +61,13 @@ describe('errorBackoff', () => {
|
||||
|
||||
it('backs off exponentially based on error count', async () => {
|
||||
const rule = errorBackoff<State, unknown>((s) => s.errors);
|
||||
const [effects, tickMs] = await apply(rule, { errors: 0 }, { errors: 3 }, [], 10000);
|
||||
const [effects, tickMs] = await apply(
|
||||
rule,
|
||||
{ errors: 0 },
|
||||
{ errors: 3 },
|
||||
[],
|
||||
10000,
|
||||
);
|
||||
// 10000 * 2^3 = 80000
|
||||
expect(tickMs).toBe(80000);
|
||||
expect(effects).toEqual([]);
|
||||
@@ -69,19 +75,37 @@ describe('errorBackoff', () => {
|
||||
|
||||
it('caps at maxMs', async () => {
|
||||
const rule = errorBackoff<State, unknown>((s) => s.errors, 50000);
|
||||
const [, tickMs] = await apply(rule, { errors: 0 }, { errors: 10 }, [], 10000);
|
||||
const [, tickMs] = await apply(
|
||||
rule,
|
||||
{ errors: 0 },
|
||||
{ errors: 10 },
|
||||
[],
|
||||
10000,
|
||||
);
|
||||
expect(tickMs).toBe(50000);
|
||||
});
|
||||
|
||||
it('passes through when zero errors', async () => {
|
||||
const rule = errorBackoff<State, unknown>((s) => s.errors);
|
||||
const [, tickMs] = await apply(rule, { errors: 0 }, { errors: 0 }, [], 15000);
|
||||
const [, tickMs] = await apply(
|
||||
rule,
|
||||
{ errors: 0 },
|
||||
{ errors: 0 },
|
||||
[],
|
||||
15000,
|
||||
);
|
||||
expect(tickMs).toBe(15000);
|
||||
});
|
||||
|
||||
it('preserves effects', async () => {
|
||||
const rule = errorBackoff<State, string>((s) => s.errors);
|
||||
const [effects] = await apply(rule, { errors: 0 }, { errors: 2 }, ['alert'], 10000);
|
||||
const [effects] = await apply(
|
||||
rule,
|
||||
{ errors: 0 },
|
||||
{ errors: 2 },
|
||||
['alert'],
|
||||
10000,
|
||||
);
|
||||
expect(effects).toEqual(['alert']);
|
||||
});
|
||||
});
|
||||
@@ -108,14 +132,26 @@ describe('adaptiveInterval', () => {
|
||||
|
||||
it('caps slowdown at slowMs', async () => {
|
||||
const rule = adaptiveInterval<State, unknown>(changed, 3000, 120000, 2.0);
|
||||
const [, tickMs] = await apply(rule, { value: 1 }, { value: 1 }, [], 100000);
|
||||
const [, tickMs] = await apply(
|
||||
rule,
|
||||
{ value: 1 },
|
||||
{ value: 1 },
|
||||
[],
|
||||
100000,
|
||||
);
|
||||
// 100000 * 2.0 = 200000, capped at 120000
|
||||
expect(tickMs).toBe(120000);
|
||||
});
|
||||
|
||||
it('preserves effects', async () => {
|
||||
const rule = adaptiveInterval<State, string>(changed, 5000, 60000);
|
||||
const [effects] = await apply(rule, { value: 1 }, { value: 2 }, ['notify'], 15000);
|
||||
const [effects] = await apply(
|
||||
rule,
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
['notify'],
|
||||
15000,
|
||||
);
|
||||
expect(effects).toEqual(['notify']);
|
||||
});
|
||||
});
|
||||
@@ -129,7 +165,9 @@ describe('dedup', () => {
|
||||
it('removes duplicate effects by kind', async () => {
|
||||
const rule = dedup<unknown, Effect>();
|
||||
const [effects, tickMs] = await apply(
|
||||
rule, {}, {},
|
||||
rule,
|
||||
{},
|
||||
{},
|
||||
[
|
||||
{ kind: 'alert', payload: 'first' },
|
||||
{ kind: 'notify', payload: 'hello' },
|
||||
@@ -144,14 +182,16 @@ describe('dedup', () => {
|
||||
expect(kinds.includes('alert')).toBeTruthy();
|
||||
expect(kinds.includes('notify')).toBeTruthy();
|
||||
const alertEffect = effects.find((e) => e.kind === 'alert')!;
|
||||
expect(alertEffect.payload).toBe('second'); // last wins
|
||||
expect(alertEffect.payload).toBe('second'); // last wins
|
||||
expect(tickMs).toBe(15000);
|
||||
});
|
||||
|
||||
it('supports custom key function', async () => {
|
||||
const rule = dedup<unknown, Effect>((e) => `${e.kind}:${e.payload}`);
|
||||
const [effects] = await apply(
|
||||
rule, {}, {},
|
||||
rule,
|
||||
{},
|
||||
{},
|
||||
[
|
||||
{ kind: 'alert', payload: 'a' },
|
||||
{ kind: 'alert', payload: 'b' },
|
||||
@@ -172,7 +212,13 @@ describe('dedup', () => {
|
||||
|
||||
it('preserves tickMs', async () => {
|
||||
const rule = dedup<unknown, Effect>();
|
||||
const [, tickMs] = await apply(rule, {}, {}, [{ kind: 'x', payload: '' }], 42000);
|
||||
const [, tickMs] = await apply(
|
||||
rule,
|
||||
{},
|
||||
{},
|
||||
[{ kind: 'x', payload: '' }],
|
||||
42000,
|
||||
);
|
||||
expect(tickMs).toBe(42000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ export function errorBackoff<S, E>(
|
||||
const [effects, tickMs] = await inner(_prev, curr);
|
||||
const errors = getErrors(curr);
|
||||
if (errors <= 0) return [effects, tickMs];
|
||||
const backed = Math.min(tickMs * Math.pow(2, errors), maxMs);
|
||||
const backed = Math.min(tickMs * 2 ** errors, maxMs);
|
||||
return [effects, backed];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
* Covers: https://github.com/oc-xiaoju/pulse/issues/6
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createStore } from './store.js';
|
||||
import type { EventRecord, VitalRecord } from './store.js';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createStore } from './store.js';
|
||||
|
||||
describe('createStore (events table + CAS)', () => {
|
||||
let dir: string;
|
||||
@@ -147,16 +145,39 @@ describe('createStore (events table + CAS)', () => {
|
||||
it('7. getLatestWhere filters by codeRev', () => {
|
||||
const store = createStore({ eventsDbPath, vitalsDbPath, objectsDir });
|
||||
|
||||
store.appendEvent({ occurredAt: 1000, kind: 'tick', key: 'cpu', codeRev: 'v1' });
|
||||
store.appendEvent({ occurredAt: 2000, kind: 'tick', key: 'cpu', codeRev: 'v2' });
|
||||
store.appendEvent({ occurredAt: 3000, kind: 'tick', key: 'cpu', codeRev: 'v1' });
|
||||
store.appendEvent({
|
||||
occurredAt: 1000,
|
||||
kind: 'tick',
|
||||
key: 'cpu',
|
||||
codeRev: 'v1',
|
||||
});
|
||||
store.appendEvent({
|
||||
occurredAt: 2000,
|
||||
kind: 'tick',
|
||||
key: 'cpu',
|
||||
codeRev: 'v2',
|
||||
});
|
||||
store.appendEvent({
|
||||
occurredAt: 3000,
|
||||
kind: 'tick',
|
||||
key: 'cpu',
|
||||
codeRev: 'v1',
|
||||
});
|
||||
|
||||
const latestV2 = store.getLatestWhere({ kind: 'tick', key: 'cpu', codeRev: 'v2' });
|
||||
const latestV2 = store.getLatestWhere({
|
||||
kind: 'tick',
|
||||
key: 'cpu',
|
||||
codeRev: 'v2',
|
||||
});
|
||||
expect(latestV2).toBeTruthy();
|
||||
expect(latestV2!.occurredAt).toBe(2000);
|
||||
expect(latestV2!.codeRev).toBe('v2');
|
||||
|
||||
const latestV1 = store.getLatestWhere({ kind: 'tick', key: 'cpu', codeRev: 'v1' });
|
||||
const latestV1 = store.getLatestWhere({
|
||||
kind: 'tick',
|
||||
key: 'cpu',
|
||||
codeRev: 'v1',
|
||||
});
|
||||
expect(latestV1).toBeTruthy();
|
||||
expect(latestV1!.occurredAt).toBe(3000);
|
||||
expect(latestV1!.codeRev).toBe('v1');
|
||||
@@ -192,7 +213,7 @@ describe('createStore (events table + CAS)', () => {
|
||||
|
||||
const ticks = store.queryByKind('tick');
|
||||
expect(ticks.length).toBe(2);
|
||||
expect(ticks.every(e => e.kind === 'tick')).toBeTruthy();
|
||||
expect(ticks.every((e) => e.kind === 'tick')).toBeTruthy();
|
||||
// Newest first
|
||||
expect(ticks[0]!.occurredAt).toBe(3000);
|
||||
expect(ticks[1]!.occurredAt).toBe(1000);
|
||||
@@ -227,7 +248,7 @@ describe('createStore (events table + CAS)', () => {
|
||||
|
||||
const result = store.queryByKind('tick', { key: 'cpu' });
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(e => e.key === 'cpu')).toBeTruthy();
|
||||
expect(result.every((e) => e.key === 'cpu')).toBeTruthy();
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -242,7 +263,7 @@ describe('createStore (events table + CAS)', () => {
|
||||
|
||||
const result = store.queryByKind('tick', { codeRev: 'abc123' });
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(e => e.codeRev === 'abc123')).toBeTruthy();
|
||||
expect(result.every((e) => e.codeRev === 'abc123')).toBeTruthy();
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -253,8 +274,8 @@ describe('createStore (events table + CAS)', () => {
|
||||
|
||||
const e1 = store.appendEvent({ occurredAt: 1000, kind: 'tick' });
|
||||
const e2 = store.appendEvent({ occurredAt: 2000, kind: 'tick' });
|
||||
const e3 = store.appendEvent({ occurredAt: 3000, kind: 'tick' });
|
||||
const e4 = store.appendEvent({ occurredAt: 4000, kind: 'collect' });
|
||||
const _e3 = store.appendEvent({ occurredAt: 3000, kind: 'tick' });
|
||||
const _e4 = store.appendEvent({ occurredAt: 4000, kind: 'collect' });
|
||||
|
||||
const after = store.getAfter(e2.id);
|
||||
expect(after.length).toBe(2);
|
||||
@@ -265,7 +286,7 @@ describe('createStore (events table + CAS)', () => {
|
||||
// With kind filter
|
||||
const afterTick = store.getAfter(e1.id, { kind: 'tick' });
|
||||
expect(afterTick.length).toBe(2);
|
||||
expect(afterTick.every(e => e.kind === 'tick')).toBeTruthy();
|
||||
expect(afterTick.every((e) => e.kind === 'tick')).toBeTruthy();
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -352,7 +373,7 @@ describe('createStore (events table + CAS)', () => {
|
||||
expect(results.length).toBe(3);
|
||||
|
||||
// Each result should have a unique ULID id
|
||||
const ids = new Set(results.map(r => r.id));
|
||||
const ids = new Set(results.map((r) => r.id));
|
||||
expect(ids.size).toBe(3);
|
||||
|
||||
// All should be retrievable
|
||||
@@ -502,7 +523,7 @@ describe('Vitals (vitals table + housekeeping)', () => {
|
||||
|
||||
const results = store.appendVitals(vitals);
|
||||
expect(results.length).toBe(3);
|
||||
const ids = new Set(results.map(r => r.id));
|
||||
const ids = new Set(results.map((r) => r.id));
|
||||
expect(ids.size).toBe(3);
|
||||
|
||||
// Verify retrievable
|
||||
@@ -552,7 +573,7 @@ describe('Vitals (vitals table + housekeeping)', () => {
|
||||
// Should keep: w0_3 (900, latest in window 0), w1_3 (1900, latest in window 1), recent (5000)
|
||||
const history = store.getVitalHistory('cpu', 10);
|
||||
expect(history.length).toBe(3);
|
||||
const hashes = history.map(v => v.hash);
|
||||
const hashes = history.map((v) => v.hash);
|
||||
expect(hashes.includes('w0_3')).toBeTruthy();
|
||||
expect(hashes.includes('w1_3')).toBeTruthy();
|
||||
expect(hashes.includes('recent')).toBeTruthy();
|
||||
@@ -620,13 +641,21 @@ describe('Split-database isolation', () => {
|
||||
// events.db should have events table but NOT vitals
|
||||
expect(eDb.prepare('SELECT * FROM events').all().length > 0).toBeTruthy();
|
||||
expect(
|
||||
eDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vitals'").get(),
|
||||
eDb
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='vitals'",
|
||||
)
|
||||
.get(),
|
||||
).toBeNull();
|
||||
|
||||
// vitals.db should have vitals table but NOT events
|
||||
expect(vDb.prepare('SELECT * FROM vitals').all().length > 0).toBeTruthy();
|
||||
expect(
|
||||
vDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get(),
|
||||
vDb
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='events'",
|
||||
)
|
||||
.get(),
|
||||
).toBeNull();
|
||||
|
||||
eDb.close();
|
||||
|
||||
+120
-64
@@ -7,23 +7,23 @@
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface EventRecord {
|
||||
id: string; // ULID
|
||||
id: string; // ULID
|
||||
occurredAt: number;
|
||||
kind: string;
|
||||
key?: string;
|
||||
hash?: string;
|
||||
codeRev?: string;
|
||||
meta?: string; // JSON string
|
||||
meta?: string; // JSON string
|
||||
}
|
||||
|
||||
export interface VitalRecord {
|
||||
id: string; // ULID
|
||||
id: string; // ULID
|
||||
occurredAt: number;
|
||||
key: string;
|
||||
hash?: string;
|
||||
@@ -41,25 +41,35 @@ export interface PulseStore {
|
||||
getLatest(kind: string, key?: string): EventRecord | null;
|
||||
|
||||
/** Get latest event with additional filters */
|
||||
getLatestWhere(opts: { kind: string; key?: string; codeRev?: string }): EventRecord | null;
|
||||
getLatestWhere(opts: {
|
||||
kind: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
}): EventRecord | null;
|
||||
|
||||
/** Get recent events (newest first) */
|
||||
getRecent(limit?: number): EventRecord[];
|
||||
|
||||
/** Query events by kind with optional filters */
|
||||
queryByKind(kind: string, opts?: {
|
||||
key?: string;
|
||||
since?: number; // occurred_at >= since
|
||||
codeRev?: string;
|
||||
limit?: number;
|
||||
}): EventRecord[];
|
||||
queryByKind(
|
||||
kind: string,
|
||||
opts?: {
|
||||
key?: string;
|
||||
since?: number; // occurred_at >= since
|
||||
codeRev?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): EventRecord[];
|
||||
|
||||
/** Get all events after a specific event id */
|
||||
getAfter(afterId: string, opts?: {
|
||||
kind?: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
}): EventRecord[];
|
||||
getAfter(
|
||||
afterId: string,
|
||||
opts?: {
|
||||
kind?: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
},
|
||||
): EventRecord[];
|
||||
|
||||
/** Check if any events exist */
|
||||
hasEvents(): boolean;
|
||||
@@ -252,24 +262,38 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
SELECT * FROM vitals WHERE key = ? ORDER BY occurred_at DESC, id DESC LIMIT 1
|
||||
`);
|
||||
|
||||
const appendVitalsManyTx = vitalsDb.transaction((vitals: Omit<VitalRecord, 'id'>[]): VitalRecord[] => {
|
||||
const results: VitalRecord[] = [];
|
||||
for (const v of vitals) {
|
||||
const id = makeUlid(v.occurredAt);
|
||||
insertVital.run(id, v.occurredAt, v.key, v.hash ?? null, v.meta ?? null);
|
||||
results.push({ id, ...v });
|
||||
}
|
||||
return results;
|
||||
});
|
||||
const appendVitalsManyTx = vitalsDb.transaction(
|
||||
(vitals: Omit<VitalRecord, 'id'>[]): VitalRecord[] => {
|
||||
const results: VitalRecord[] = [];
|
||||
for (const v of vitals) {
|
||||
const id = makeUlid(v.occurredAt);
|
||||
insertVital.run(
|
||||
id,
|
||||
v.occurredAt,
|
||||
v.key,
|
||||
v.hash ?? null,
|
||||
v.meta ?? null,
|
||||
);
|
||||
results.push({ id, ...v });
|
||||
}
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
const archiveVitalsTx = vitalsDb.transaction((olderThan: number): number => {
|
||||
vitalsDb.prepare(`INSERT INTO vitals_archive SELECT * FROM vitals WHERE occurred_at < ?`).run(olderThan);
|
||||
const result = vitalsDb.prepare(`DELETE FROM vitals WHERE occurred_at < ?`).run(olderThan);
|
||||
vitalsDb
|
||||
.prepare(
|
||||
`INSERT INTO vitals_archive SELECT * FROM vitals WHERE occurred_at < ?`,
|
||||
)
|
||||
.run(olderThan);
|
||||
const result = vitalsDb
|
||||
.prepare(`DELETE FROM vitals WHERE occurred_at < ?`)
|
||||
.run(olderThan);
|
||||
return result.changes;
|
||||
});
|
||||
|
||||
// Batch insert transaction — events
|
||||
const insertMany = eventsDb.transaction((records: EventRecord[]) => {
|
||||
const _insertMany = eventsDb.transaction((records: EventRecord[]) => {
|
||||
for (const r of records) {
|
||||
insertEvent.run(
|
||||
r.id,
|
||||
@@ -298,24 +322,26 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
return record;
|
||||
}
|
||||
|
||||
const appendManyTx = eventsDb.transaction((events: Omit<EventRecord, 'id'>[]): EventRecord[] => {
|
||||
const results: EventRecord[] = [];
|
||||
for (const event of events) {
|
||||
const id = makeUlid(event.occurredAt);
|
||||
const record: EventRecord = { id, ...event };
|
||||
insertEvent.run(
|
||||
id,
|
||||
event.occurredAt,
|
||||
event.kind,
|
||||
event.key ?? null,
|
||||
event.hash ?? null,
|
||||
event.codeRev ?? null,
|
||||
event.meta ?? null,
|
||||
);
|
||||
results.push(record);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
const appendManyTx = eventsDb.transaction(
|
||||
(events: Omit<EventRecord, 'id'>[]): EventRecord[] => {
|
||||
const results: EventRecord[] = [];
|
||||
for (const event of events) {
|
||||
const id = makeUlid(event.occurredAt);
|
||||
const record: EventRecord = { id, ...event };
|
||||
insertEvent.run(
|
||||
id,
|
||||
event.occurredAt,
|
||||
event.kind,
|
||||
event.key ?? null,
|
||||
event.hash ?? null,
|
||||
event.codeRev ?? null,
|
||||
event.meta ?? null,
|
||||
);
|
||||
results.push(record);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
appendEvent(event: Omit<EventRecord, 'id'>): EventRecord {
|
||||
@@ -327,11 +353,19 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
},
|
||||
|
||||
getLatest(kind: string, key?: string): EventRecord | null {
|
||||
const row = selectLatest.get(kind, key ?? null, key ?? null) as RawRow | null;
|
||||
const row = selectLatest.get(
|
||||
kind,
|
||||
key ?? null,
|
||||
key ?? null,
|
||||
) as RawRow | null;
|
||||
return row ? rowToRecord(row) : null;
|
||||
},
|
||||
|
||||
getLatestWhere(opts: { kind: string; key?: string; codeRev?: string }): EventRecord | null {
|
||||
getLatestWhere(opts: {
|
||||
kind: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
}): EventRecord | null {
|
||||
const conditions: string[] = ['kind = ?'];
|
||||
const params: (string | number | null)[] = [opts.kind];
|
||||
|
||||
@@ -354,12 +388,15 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
return (eventsDb.prepare(sql).all(limit) as RawRow[]).map(rowToRecord);
|
||||
},
|
||||
|
||||
queryByKind(kind: string, opts?: {
|
||||
key?: string;
|
||||
since?: number;
|
||||
codeRev?: string;
|
||||
limit?: number;
|
||||
}): EventRecord[] {
|
||||
queryByKind(
|
||||
kind: string,
|
||||
opts?: {
|
||||
key?: string;
|
||||
since?: number;
|
||||
codeRev?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): EventRecord[] {
|
||||
const conditions: string[] = ['kind = ?'];
|
||||
const params: (string | number | null)[] = [kind];
|
||||
|
||||
@@ -382,14 +419,19 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
params.push(opts.limit);
|
||||
}
|
||||
|
||||
return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(rowToRecord);
|
||||
return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(
|
||||
rowToRecord,
|
||||
);
|
||||
},
|
||||
|
||||
getAfter(afterId: string, opts?: {
|
||||
kind?: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
}): EventRecord[] {
|
||||
getAfter(
|
||||
afterId: string,
|
||||
opts?: {
|
||||
kind?: string;
|
||||
key?: string;
|
||||
codeRev?: string;
|
||||
},
|
||||
): EventRecord[] {
|
||||
const conditions: string[] = ['id > ?'];
|
||||
const params: (string | number | null)[] = [afterId];
|
||||
|
||||
@@ -407,7 +449,9 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
}
|
||||
|
||||
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`;
|
||||
return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(rowToRecord);
|
||||
return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(
|
||||
rowToRecord,
|
||||
);
|
||||
},
|
||||
|
||||
hasEvents(): boolean {
|
||||
@@ -438,7 +482,13 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
|
||||
appendVital(vital: Omit<VitalRecord, 'id'>): VitalRecord {
|
||||
const id = makeUlid(vital.occurredAt);
|
||||
insertVital.run(id, vital.occurredAt, vital.key, vital.hash ?? null, vital.meta ?? null);
|
||||
insertVital.run(
|
||||
id,
|
||||
vital.occurredAt,
|
||||
vital.key,
|
||||
vital.hash ?? null,
|
||||
vital.meta ?? null,
|
||||
);
|
||||
const record: VitalRecord = { id, ...vital };
|
||||
return record;
|
||||
},
|
||||
@@ -454,14 +504,20 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
|
||||
getVitalHistory(key: string, limit: number = 20): VitalRecord[] {
|
||||
const sql = `SELECT * FROM vitals WHERE key = ? ORDER BY occurred_at DESC, id DESC LIMIT ?`;
|
||||
return (vitalsDb.prepare(sql).all(key, limit) as RawVitalRow[]).map(vitalRowToRecord);
|
||||
return (vitalsDb.prepare(sql).all(key, limit) as RawVitalRow[]).map(
|
||||
vitalRowToRecord,
|
||||
);
|
||||
},
|
||||
|
||||
archiveVitals(olderThan: number): number {
|
||||
return archiveVitalsTx(olderThan);
|
||||
},
|
||||
|
||||
downsampleVitals(key: string, intervalMs: number, olderThan: number): number {
|
||||
downsampleVitals(
|
||||
key: string,
|
||||
intervalMs: number,
|
||||
olderThan: number,
|
||||
): number {
|
||||
// Note: SQLite does not support parameter binding inside PARTITION BY expressions.
|
||||
// We safely cast intervalMs to integer and embed it directly in the SQL.
|
||||
const safeInterval = Math.floor(Math.abs(intervalMs));
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
* @uncaged/pulse — Watcher Tests
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { startWatcher, type WatcherDef, type WatcherHandle } from './watcher.js';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { PulseStore, VitalRecord } from './store.js';
|
||||
import {
|
||||
startWatcher,
|
||||
type WatcherDef,
|
||||
type WatcherHandle,
|
||||
} from './watcher.js';
|
||||
|
||||
// Mock store implementation
|
||||
const createMockStore = (): PulseStore => ({
|
||||
@@ -106,7 +110,8 @@ describe('watcher', () => {
|
||||
});
|
||||
|
||||
test('error handling continues loop', async () => {
|
||||
const mockCollect = vi.fn()
|
||||
const mockCollect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('First error'))
|
||||
.mockResolvedValue({ data: 'success' });
|
||||
const mockShouldWake = vi.fn().mockReturnValue(false);
|
||||
@@ -125,7 +130,7 @@ describe('watcher', () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[watcher:error-watcher] error during tick:',
|
||||
expect.any(Error)
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Second tick - success
|
||||
@@ -186,12 +191,12 @@ describe('watcher', () => {
|
||||
test('getVitalHistory called with correct parameters', async () => {
|
||||
const mockCollect = vi.fn().mockResolvedValue({ data: 'history-test' });
|
||||
const mockShouldWake = vi.fn().mockReturnValue(false);
|
||||
|
||||
|
||||
const mockVitals: VitalRecord[] = [
|
||||
{ occurredAt: Date.now(), key: 'test-key', hash: 'hash-1' },
|
||||
{ occurredAt: Date.now() - 5000, key: 'test-key', hash: 'hash-2' },
|
||||
];
|
||||
|
||||
|
||||
(mockStore.getVitalHistory as any).mockReturnValue(mockVitals);
|
||||
|
||||
const def: WatcherDef = {
|
||||
@@ -208,4 +213,4 @@ describe('watcher', () => {
|
||||
expect(mockStore.getVitalHistory).toHaveBeenCalledWith('history-key', 12);
|
||||
expect(mockShouldWake).toHaveBeenCalledWith(mockVitals);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { registerInitCommand } from './commands/init.js';
|
||||
import { registerDaemonCommand } from './commands/daemon.js';
|
||||
import { registerTickCommand } from './commands/tick.js';
|
||||
import { registerListCommand } from './commands/list.js';
|
||||
import { registerInspectCommand } from './commands/inspect.js';
|
||||
import { registerDevCommand } from './commands/dev.js';
|
||||
import { registerDeployCommand } from './commands/deploy.js';
|
||||
import { registerDevCommand } from './commands/dev.js';
|
||||
import { registerGcCommand } from './commands/gc.js';
|
||||
import { registerInitCommand } from './commands/init.js';
|
||||
import { registerInspectCommand } from './commands/inspect.js';
|
||||
import { registerListCommand } from './commands/list.js';
|
||||
import { registerTickCommand } from './commands/tick.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -22,7 +22,10 @@ program
|
||||
.name('upulse')
|
||||
.description('Pulse CLI — Agent 的自主神经系统管理工具')
|
||||
.version('0.1.0')
|
||||
.option('-d, --dir <path>', 'Pulse base directory (overrides PULSE_BASE_DIR)');
|
||||
.option(
|
||||
'-d, --dir <path>',
|
||||
'Pulse base directory (overrides PULSE_BASE_DIR)',
|
||||
);
|
||||
|
||||
registerInitCommand(program);
|
||||
registerDaemonCommand(program);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
import { daemonStart, daemonStop, daemonStatus } from '../daemon.js';
|
||||
import { daemonStart, daemonStatus, daemonStop } from '../daemon.js';
|
||||
|
||||
export function registerDaemonCommand(program: Command): void {
|
||||
const daemon = program
|
||||
|
||||
@@ -5,24 +5,36 @@
|
||||
* All state changes are append-only events.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir, type UpulseConfig } from '../config.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
import { daemonStart, daemonStop, isDaemonRunning } from '../daemon.js';
|
||||
import { gitExec, gitMerge, gitResetHard, gitRevert } from '../git.js';
|
||||
import { daemonStop, daemonStart, isDaemonRunning } from '../daemon.js';
|
||||
import { openStore, openOrCreateStore, type PulseStore, type EventRecord } from '../store.js';
|
||||
import {
|
||||
type EventRecord,
|
||||
openOrCreateStore,
|
||||
openStore,
|
||||
type PulseStore,
|
||||
} from '../store.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function typeCheck(cwd: string): boolean {
|
||||
try {
|
||||
// Pure type checking — no emit, no dist/ needed
|
||||
execSync('bunx tsc --noEmit', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
execSync('bunx tsc --noEmit', {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? (err as { stderr?: string }).stderr ?? err.message : String(err);
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? ((err as { stderr?: string }).stderr ?? err.message)
|
||||
: String(err);
|
||||
console.error(`Type check failed:\n${msg}`);
|
||||
return false;
|
||||
}
|
||||
@@ -41,7 +53,10 @@ function getCurrentCodeRev(store: PulseStore): string | undefined {
|
||||
* Find the previous promote event with a different code_rev than the current one.
|
||||
* queryByKind returns newest-first, so find the first with a different codeRev.
|
||||
*/
|
||||
function findPreviousPromote(store: PulseStore, currentCodeRev: string | undefined): EventRecord | null {
|
||||
function findPreviousPromote(
|
||||
store: PulseStore,
|
||||
currentCodeRev: string | undefined,
|
||||
): EventRecord | null {
|
||||
const allPromotes = store.queryByKind('promote');
|
||||
for (const promote of allPromotes) {
|
||||
if (promote.codeRev !== currentCodeRev) {
|
||||
@@ -64,18 +79,22 @@ async function tryWriteMigrateEvents(
|
||||
if (!existsSync(migrateFile)) return;
|
||||
|
||||
try {
|
||||
const mod = await import(migrateFile) as Record<string, unknown>;
|
||||
const mod = (await import(migrateFile)) as Record<string, unknown>;
|
||||
const migrateFn = (mod.default ?? mod.migrate) as
|
||||
| ((snapshot: Record<string, unknown>) => Record<string, unknown>)
|
||||
| undefined;
|
||||
if (!migrateFn) return;
|
||||
|
||||
// Build a minimal current snapshot from store for migration
|
||||
const { findEffectiveEpoch, rebuildSnapshot } = await import('@uncaged/pulse');
|
||||
const { findEffectiveEpoch, rebuildSnapshot } = await import(
|
||||
'@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[])];
|
||||
const senseKeys = [
|
||||
...new Set(collects.map((e) => e.key).filter(Boolean) as string[]),
|
||||
];
|
||||
const currentSnapshot = rebuildSnapshot(store, senseKeys, epoch);
|
||||
const migrated = migrateFn(currentSnapshot);
|
||||
|
||||
@@ -92,7 +111,9 @@ async function tryWriteMigrateEvents(
|
||||
}
|
||||
console.log(' ✓ migrate events written');
|
||||
} catch (err: unknown) {
|
||||
console.error(` ✗ migrate failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
` ✗ migrate failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(' Promote aborted. Fix migrate.ts and try again.');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -107,8 +128,10 @@ async function tryWriteInitEvents(
|
||||
if (!existsSync(initFile)) return;
|
||||
|
||||
try {
|
||||
const mod = await import(initFile) as Record<string, unknown>;
|
||||
const initSenses = (mod.default ?? mod.initSenses) as Record<string, unknown> | undefined;
|
||||
const mod = (await import(initFile)) as Record<string, unknown>;
|
||||
const initSenses = (mod.default ?? mod.initSenses) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!initSenses || typeof initSenses !== 'object') return;
|
||||
|
||||
for (const [key, data] of Object.entries(initSenses)) {
|
||||
@@ -122,7 +145,9 @@ async function tryWriteInitEvents(
|
||||
}
|
||||
console.log(' ✓ init events written');
|
||||
} catch (err: unknown) {
|
||||
console.warn(` ⚠ initSenses skipped: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.warn(
|
||||
` ⚠ initSenses skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +160,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
|
||||
deploy
|
||||
.command('promote')
|
||||
.description('Merge staging → engine (tsc verify + version events + daemon reload)')
|
||||
.description(
|
||||
'Merge staging → engine (tsc verify + version events + daemon reload)',
|
||||
)
|
||||
.action(async () => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const running = isDaemonRunning(config);
|
||||
@@ -157,7 +184,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
gitMerge(config.engine.path, 'staging');
|
||||
console.log(' ✓ merge successful');
|
||||
} catch (err: unknown) {
|
||||
console.error(`Error: merge failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Error: merge failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error('Resolve conflicts manually in engine/ and retry.');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -165,7 +194,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
// Step 3: Type-check engine (second verification)
|
||||
console.log('Step 3: Type-checking engine...');
|
||||
if (!typeCheck(config.engine.path)) {
|
||||
console.error('Error: engine type check failed after merge. Rolling back...');
|
||||
console.error(
|
||||
'Error: engine type check failed after merge. Rolling back...',
|
||||
);
|
||||
gitResetHard(config.engine.path, 'HEAD~1');
|
||||
console.error('Rolled back to previous state.');
|
||||
process.exit(1);
|
||||
@@ -174,7 +205,10 @@ export function registerDeployCommand(program: Command): void {
|
||||
|
||||
// Step 4: Write version events
|
||||
console.log('Step 4: Writing version events...');
|
||||
const store = openOrCreateStore(config.store.eventsDbFile, config.store.vitalsDbFile);
|
||||
const store = openOrCreateStore(
|
||||
config.store.eventsDbFile,
|
||||
config.store.vitalsDbFile,
|
||||
);
|
||||
{
|
||||
const currentCodeRev = getCurrentCodeRev(store);
|
||||
const newCodeRev = getGitHash(config.engine.path);
|
||||
@@ -192,7 +226,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
codeRev: newCodeRev,
|
||||
meta: JSON.stringify({ from: currentCodeRev ?? null }),
|
||||
});
|
||||
console.log(` ✓ promote event: ${currentCodeRev ?? '(cold start)'} → ${newCodeRev}`);
|
||||
console.log(
|
||||
` ✓ promote event: ${currentCodeRev ?? '(cold start)'} → ${newCodeRev}`,
|
||||
);
|
||||
|
||||
store.close();
|
||||
}
|
||||
@@ -205,7 +241,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
daemonStart(config, false);
|
||||
console.log(' ✓ daemon reloaded');
|
||||
} catch (err: unknown) {
|
||||
console.error(`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,16 +252,23 @@ export function registerDeployCommand(program: Command): void {
|
||||
|
||||
deploy
|
||||
.command('rollback')
|
||||
.description('Roll back to previous version (append-only: writes rollback event, reverts engine)')
|
||||
.description(
|
||||
'Roll back to previous version (append-only: writes rollback event, reverts engine)',
|
||||
)
|
||||
.action(() => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const running = isDaemonRunning(config);
|
||||
|
||||
// Step 1: Open store and find versions
|
||||
console.log('Step 1: Finding versions...');
|
||||
const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile);
|
||||
const store = openStore(
|
||||
config.store.eventsDbFile,
|
||||
config.store.vitalsDbFile,
|
||||
);
|
||||
if (!store) {
|
||||
console.error('Error: no events.db found. Cannot determine versions for rollback.');
|
||||
console.error(
|
||||
'Error: no events.db found. Cannot determine versions for rollback.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -234,7 +279,10 @@ export function registerDeployCommand(program: Command): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previousPromote = findPreviousPromote(store, currentPromote.codeRev);
|
||||
const previousPromote = findPreviousPromote(
|
||||
store,
|
||||
currentPromote.codeRev,
|
||||
);
|
||||
if (!previousPromote) {
|
||||
console.error('Error: no previous version to roll back to.');
|
||||
store.close();
|
||||
@@ -264,7 +312,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
gitRevert(config.engine.path, 'HEAD');
|
||||
console.log(' ✓ reverted');
|
||||
} catch (err: unknown) {
|
||||
console.error(`Error: revert failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Error: revert failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -284,7 +334,9 @@ export function registerDeployCommand(program: Command): void {
|
||||
daemonStart(config, false);
|
||||
console.log(' ✓ daemon reloaded');
|
||||
} catch (err: unknown) {
|
||||
console.error(`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* commands/dev.ts — upulse dev path/build
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
|
||||
export function registerDevCommand(program: Command): void {
|
||||
const dev = program
|
||||
@@ -19,9 +19,9 @@ export function registerDevCommand(program: Command): void {
|
||||
.action((opts: { engine?: boolean }) => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
if (opts.engine) {
|
||||
process.stdout.write(config.engine.path + '\n');
|
||||
process.stdout.write(`${config.engine.path}\n`);
|
||||
} else {
|
||||
process.stdout.write(config.staging.path + '\n');
|
||||
process.stdout.write(`${config.staging.path}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,10 +30,19 @@ export function registerDevCommand(program: Command): void {
|
||||
.description('tsc compile staging/')
|
||||
.action(() => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const tscBin = join(config.engine.path, 'node_modules', 'typescript', 'bin', 'tsc');
|
||||
const tscBin = join(
|
||||
config.engine.path,
|
||||
'node_modules',
|
||||
'typescript',
|
||||
'bin',
|
||||
'tsc',
|
||||
);
|
||||
console.log(`Compiling staging (${config.staging.path})...`);
|
||||
try {
|
||||
execSync(`bun ${tscBin}`, { cwd: config.staging.path, stdio: 'inherit' });
|
||||
execSync(`bun ${tscBin}`, {
|
||||
cwd: config.staging.path,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log('✓ Build successful');
|
||||
} catch {
|
||||
console.error('Error: tsc compilation failed.');
|
||||
|
||||
@@ -11,7 +11,9 @@ import { openStore } from '../store.js';
|
||||
function parseDuration(s: string): number {
|
||||
const match = s.match(/^(\d+)(ms|s|m|h|d)?$/);
|
||||
if (!match) {
|
||||
console.error(`Error: invalid duration "${s}". Use format: 7d, 24h, 30m, 60s, 1000ms`);
|
||||
console.error(
|
||||
`Error: invalid duration "${s}". Use format: 7d, 24h, 30m, 60s, 1000ms`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const val = parseInt(match[1], 10);
|
||||
@@ -33,12 +35,22 @@ export function registerGcCommand(program: Command): void {
|
||||
|
||||
gc.command('vitals')
|
||||
.description('Archive old vitals and optionally downsample')
|
||||
.option('--keep <duration>', 'Move vitals older than this to archive (default: 7d)', '7d')
|
||||
.option('--downsample <duration>', 'Downsample interval (e.g., 1m, 5m). If omitted, no downsampling.')
|
||||
.option(
|
||||
'--keep <duration>',
|
||||
'Move vitals older than this to archive (default: 7d)',
|
||||
'7d',
|
||||
)
|
||||
.option(
|
||||
'--downsample <duration>',
|
||||
'Downsample interval (e.g., 1m, 5m). If omitted, no downsampling.',
|
||||
)
|
||||
.option('--dry-run', 'Show what would happen without making changes', false)
|
||||
.action((opts: { keep: string; downsample?: string; dryRun: boolean }) => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile);
|
||||
const store = openStore(
|
||||
config.store.eventsDbFile,
|
||||
config.store.vitalsDbFile,
|
||||
);
|
||||
|
||||
if (!store) {
|
||||
console.log('No events.db found. Daemon may not have run yet.');
|
||||
@@ -57,12 +69,14 @@ export function registerGcCommand(program: Command): void {
|
||||
|
||||
if (opts.downsample) {
|
||||
const intervalMs = parseDuration(opts.downsample);
|
||||
console.log(`Downsample interval: ${opts.downsample} (${intervalMs}ms)`);
|
||||
console.log(
|
||||
`Downsample interval: ${opts.downsample} (${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
if (!opts.dryRun) {
|
||||
// Get unique keys from vitals for downsampling
|
||||
// We downsample per-key before archiving
|
||||
const keys = (store as any).__db
|
||||
const _keys = (store as any).__db
|
||||
? [] // fallback: skip key enumeration in dry-run
|
||||
: [];
|
||||
|
||||
@@ -81,12 +95,18 @@ export function registerGcCommand(program: Command): void {
|
||||
// Actually, store has no listKeys — let's just run the SQL directly via a workaround.
|
||||
// The cleanest solution: archive includes downsampling semantics.
|
||||
// For now, we skip key enumeration and inform the user.
|
||||
console.log(' Note: use --downsample with specific key names (not yet supported in batch mode).');
|
||||
console.log(' Downsampling skipped. Use the programmatic API for per-key downsampling.');
|
||||
console.log(
|
||||
' Note: use --downsample with specific key names (not yet supported in batch mode).',
|
||||
);
|
||||
console.log(
|
||||
' Downsampling skipped. Use the programmatic API for per-key downsampling.',
|
||||
);
|
||||
|
||||
console.log('\n[2/2] Archiving vitals...');
|
||||
const archived = store.archiveVitals(olderThan);
|
||||
console.log(` Archived ${archived} vital(s) older than ${olderThanStr}.`);
|
||||
console.log(
|
||||
` Archived ${archived} vital(s) older than ${olderThanStr}.`,
|
||||
);
|
||||
} else {
|
||||
console.log('\n(dry-run) Would downsample and archive vitals.');
|
||||
}
|
||||
@@ -94,7 +114,9 @@ export function registerGcCommand(program: Command): void {
|
||||
// Archive only
|
||||
if (!opts.dryRun) {
|
||||
const archived = store.archiveVitals(olderThan);
|
||||
console.log(`\nArchived ${archived} vital(s) older than ${olderThanStr}.`);
|
||||
console.log(
|
||||
`\nArchived ${archived} vital(s) older than ${olderThanStr}.`,
|
||||
);
|
||||
} else {
|
||||
console.log('\n(dry-run) Would archive vitals.');
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import { initUpulse } from '../init.js';
|
||||
import { resolveDir } from '../config.js';
|
||||
import { initUpulse } from '../init.js';
|
||||
|
||||
export function registerInitCommand(program: Command): void {
|
||||
program
|
||||
|
||||
@@ -11,12 +11,19 @@ import { openStore } from '../store.js';
|
||||
function parseDuration(s: string): number {
|
||||
const match = s.match(/^(\d+)(m|h|d|s)?$/);
|
||||
if (!match) {
|
||||
console.error(`Error: invalid duration "${s}". Use format: 1h, 30m, 7d, 3600s`);
|
||||
console.error(
|
||||
`Error: invalid duration "${s}". Use format: 1h, 30m, 7d, 3600s`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const val = parseInt(match[1], 10);
|
||||
const unit = match[2] ?? 'm';
|
||||
const multiplier: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
||||
const multiplier: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
};
|
||||
return val * (multiplier[unit] ?? 60_000);
|
||||
}
|
||||
|
||||
@@ -31,7 +38,10 @@ export function registerInspectCommand(program: Command): void {
|
||||
.option('--since <duration>', 'Time window (e.g., 1h, 30m)', '1h')
|
||||
.action((opts: { since: string }) => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile);
|
||||
const store = openStore(
|
||||
config.store.eventsDbFile,
|
||||
config.store.vitalsDbFile,
|
||||
);
|
||||
|
||||
if (!store) {
|
||||
console.log('No events.db found. Daemon may not have run yet.');
|
||||
@@ -64,7 +74,10 @@ export function registerInspectCommand(program: Command): void {
|
||||
.option('--limit <n>', 'Number of ticks to show', '10')
|
||||
.action((opts: { limit: string }) => {
|
||||
const config = loadConfig(resolveDir(program.opts().dir));
|
||||
const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile);
|
||||
const store = openStore(
|
||||
config.store.eventsDbFile,
|
||||
config.store.vitalsDbFile,
|
||||
);
|
||||
|
||||
if (!store) {
|
||||
console.log('No events.db found. Daemon may not have run yet.');
|
||||
@@ -79,8 +92,12 @@ export function registerInspectCommand(program: Command): void {
|
||||
} else {
|
||||
console.log(`Recent ticks (last ${limit}):`);
|
||||
console.log('');
|
||||
console.log(' Timestamp | tickMs | Effects | Duration');
|
||||
console.log(' ------------------------------------------------------------------');
|
||||
console.log(
|
||||
' Timestamp | tickMs | Effects | Duration',
|
||||
);
|
||||
console.log(
|
||||
' ------------------------------------------------------------------',
|
||||
);
|
||||
for (const tick of ticks) {
|
||||
const ts = new Date(tick.occurredAt).toISOString();
|
||||
let tickMs = '-';
|
||||
@@ -90,13 +107,17 @@ export function registerInspectCommand(program: Command): void {
|
||||
try {
|
||||
const meta = JSON.parse(tick.meta);
|
||||
if (meta.tick_ms !== undefined) tickMs = String(meta.tick_ms);
|
||||
if (meta.effect_count !== undefined) effectCount = String(meta.effect_count);
|
||||
if (meta.duration_ms !== undefined) durationMs = `${meta.duration_ms}ms`;
|
||||
if (meta.effect_count !== undefined)
|
||||
effectCount = String(meta.effect_count);
|
||||
if (meta.duration_ms !== undefined)
|
||||
durationMs = `${meta.duration_ms}ms`;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
console.log(` ${ts} | ${tickMs.padStart(6)} | ${effectCount.padStart(7)} | ${durationMs}`);
|
||||
console.log(
|
||||
` ${ts} | ${tickMs.padStart(6)} | ${effectCount.padStart(7)} | ${durationMs}`,
|
||||
);
|
||||
}
|
||||
console.log(`\n Total: ${ticks.length} tick(s)`);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* commands/list.ts — upulse list (show current rule chain)
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function registerListCommand(program: Command): void {
|
||||
program
|
||||
@@ -22,7 +22,7 @@ export function registerListCommand(program: Command): void {
|
||||
|
||||
// List rule files sorted by name (number prefix = order)
|
||||
const files = readdirSync(rulesDir)
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
.sort();
|
||||
|
||||
if (files.length === 0) {
|
||||
@@ -50,13 +50,15 @@ export function registerListCommand(program: Command): void {
|
||||
const configFile = join(config.engine.path, 'pulse.config.ts');
|
||||
if (existsSync(configFile)) {
|
||||
const configContent = readFileSync(configFile, 'utf-8');
|
||||
const rulesMatch = configContent.match(/const rules\s*=\s*\[([\s\S]*?)\]/);
|
||||
const rulesMatch = configContent.match(
|
||||
/const rules\s*=\s*\[([\s\S]*?)\]/,
|
||||
);
|
||||
if (rulesMatch) {
|
||||
console.log(`\n Inline rules in pulse.config.ts:`);
|
||||
const inlineRules = rulesMatch[1]
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l && !l.startsWith('//') && !l.startsWith(']'));
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('//') && !l.startsWith(']'));
|
||||
for (const rule of inlineRules) {
|
||||
console.log(` ${rule.replace(/,\s*$/, '')}`);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* Manually trigger one tick using rebuildSnapshot + collectors.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { Command } from 'commander';
|
||||
import { loadConfig, resolveDir } from '../config.js';
|
||||
|
||||
export function registerTickCommand(program: Command): void {
|
||||
program
|
||||
@@ -22,10 +22,24 @@ export function registerTickCommand(program: Command): void {
|
||||
|
||||
try {
|
||||
// Import @uncaged/pulse from engine's node_modules
|
||||
const pulseModulePath = join(enginePath, 'node_modules', '@uncaged', 'pulse', 'dist', 'index.js');
|
||||
const pulseModulePath = join(
|
||||
enginePath,
|
||||
'node_modules',
|
||||
'@uncaged',
|
||||
'pulse',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
let rebuildSnapshot: (store: unknown, senseKeys: string[]) => unknown;
|
||||
let createStore: (options: { eventsDbPath: string; vitalsDbPath: string; objectsDir: string }) => unknown;
|
||||
let composeRules: (rules: unknown[], defaultTickMs?: number) => (prev: unknown, curr: unknown) => Promise<[unknown[], number]>;
|
||||
let createStore: (options: {
|
||||
eventsDbPath: string;
|
||||
vitalsDbPath: string;
|
||||
objectsDir: string;
|
||||
}) => unknown;
|
||||
let composeRules: (
|
||||
rules: unknown[],
|
||||
defaultTickMs?: number,
|
||||
) => (prev: unknown, curr: unknown) => Promise<[unknown[], number]>;
|
||||
|
||||
if (existsSync(pulseModulePath)) {
|
||||
const pulseModule = await import(pathToFileURL(pulseModulePath).href);
|
||||
@@ -33,15 +47,20 @@ export function registerTickCommand(program: Command): void {
|
||||
createStore = pulseModule.createStore;
|
||||
composeRules = pulseModule.composeRules;
|
||||
} else {
|
||||
console.error('Error: @uncaged/pulse not found in engine node_modules.');
|
||||
console.error(
|
||||
'Error: @uncaged/pulse not found in engine node_modules.',
|
||||
);
|
||||
console.error('Run: cd ~/.upulse/engine && bun install');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Import collector (.ts source — Bun runs TS natively)
|
||||
const collectSystemPath = pathToFileURL(join(enginePath, 'executors', 'system.ts')).href;
|
||||
const collectSystemPath = pathToFileURL(
|
||||
join(enginePath, 'executors', 'system.ts'),
|
||||
).href;
|
||||
const collectorModule = await import(collectSystemPath);
|
||||
const collectSystem = collectorModule.collectSystem ?? collectorModule.collect;
|
||||
const collectSystem =
|
||||
collectorModule.collectSystem ?? collectorModule.collect;
|
||||
|
||||
// Import rules (dynamically discover .ts rule files)
|
||||
const rulesDir = join(enginePath, 'rules');
|
||||
@@ -71,7 +90,11 @@ export function registerTickCommand(program: Command): void {
|
||||
const eventsDbPath = config.store.eventsDbFile;
|
||||
const vitalsDbPath = config.store.vitalsDbFile;
|
||||
const objectsDir = config.store.objectsDir;
|
||||
const store = createStore({ eventsDbPath, vitalsDbPath, objectsDir }) as {
|
||||
const store = createStore({
|
||||
eventsDbPath,
|
||||
vitalsDbPath,
|
||||
objectsDir,
|
||||
}) as {
|
||||
hasEvents(): boolean;
|
||||
appendEvent(e: Record<string, unknown>): unknown;
|
||||
putObject(d: unknown): string;
|
||||
@@ -92,7 +115,10 @@ export function registerTickCommand(program: Command): void {
|
||||
hash,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Warning: collector "${key}" failed:`, err instanceof Error ? err.message : String(err));
|
||||
console.error(
|
||||
`Warning: collector "${key}" failed:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +186,9 @@ export function registerTickCommand(program: Command): void {
|
||||
});
|
||||
console.log(` ✓ Collected: ${e.key}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ Collection failed for ${e.key}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
` ✗ Collection failed for ${e.key}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +209,7 @@ export function registerTickCommand(program: Command): void {
|
||||
}
|
||||
console.log('\n✓ Effects handled (tick mode)');
|
||||
}
|
||||
|
||||
|
||||
// Record events like runPulse does
|
||||
if (effects.length > 0) {
|
||||
const effectsHash = store.putObject(effects);
|
||||
@@ -192,7 +220,7 @@ export function registerTickCommand(program: Command): void {
|
||||
meta: JSON.stringify({ count: effects.length }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Record tick event
|
||||
store.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
@@ -204,7 +232,9 @@ export function registerTickCommand(program: Command): void {
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error(`Error during tick: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(
|
||||
`Error during tick: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* config.test.ts — resolveDir + loadConfig tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { resolveDir, defaultConfig, saveConfig, loadConfig } from './config.js';
|
||||
import { defaultConfig, loadConfig, resolveDir, saveConfig } from './config.js';
|
||||
|
||||
describe('resolveDir', () => {
|
||||
const origEnv = process.env.PULSE_BASE_DIR;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* config.ts — ~/.upulse/config.json management
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface UpulseConfig {
|
||||
dir: string;
|
||||
@@ -77,5 +77,9 @@ export function loadConfig(dir?: string): UpulseConfig {
|
||||
}
|
||||
|
||||
export function saveConfig(config: UpulseConfig): void {
|
||||
writeFileSync(configPath(config.dir), JSON.stringify(config, null, 2), 'utf-8');
|
||||
writeFileSync(
|
||||
configPath(config.dir),
|
||||
JSON.stringify(config, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
* daemon.ts — daemon lifecycle management (start/stop/status)
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync } from 'node:fs';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import type { UpulseConfig } from './config.js';
|
||||
import { openStore } from './store.js';
|
||||
|
||||
export function daemonStart(config: UpulseConfig, foreground: boolean = false): void {
|
||||
export function daemonStart(
|
||||
config: UpulseConfig,
|
||||
foreground: boolean = false,
|
||||
): void {
|
||||
const enginePath = config.engine.path;
|
||||
const tsEntry = `${enginePath}/pulse.config.ts`;
|
||||
|
||||
@@ -37,7 +47,9 @@ export function daemonStart(config: UpulseConfig, foreground: boolean = false):
|
||||
|
||||
// Check if already running
|
||||
if (isDaemonRunning(config)) {
|
||||
console.error('Error: daemon is already running. Stop it first with "upulse daemon stop".');
|
||||
console.error(
|
||||
'Error: daemon is already running. Stop it first with "upulse daemon stop".',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -124,7 +136,9 @@ export function daemonStatus(config: UpulseConfig): void {
|
||||
try {
|
||||
const meta = JSON.parse(lastTick.meta);
|
||||
detail = ` (${meta.effect_count ?? 0} effects, ${meta.tick_ms ?? '-'}ms interval)`;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
console.log(`Last tick: ${ago}s ago${detail}`);
|
||||
} else {
|
||||
@@ -138,7 +152,7 @@ function readPid(config: UpulseConfig): number | null {
|
||||
if (!existsSync(config.daemon.pidFile)) return null;
|
||||
const raw = readFileSync(config.daemon.pidFile, 'utf-8').trim();
|
||||
const pid = parseInt(raw, 10);
|
||||
return isNaN(pid) ? null : pid;
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Provides isolated temp directories, CLI runner, and DB query helpers.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -76,10 +76,14 @@ export interface RunOptions {
|
||||
* - Sets PULSE_BASE_DIR so engine's pulse.config.ts uses the right paths.
|
||||
* - Uses `bun` to run the TypeScript CLI directly.
|
||||
*/
|
||||
export function runUpulse(ctx: E2EContext, args: string, options?: RunOptions): string {
|
||||
export function runUpulse(
|
||||
ctx: E2EContext,
|
||||
args: string,
|
||||
options?: RunOptions,
|
||||
): string {
|
||||
const timeout = options?.timeout ?? 120_000;
|
||||
const env: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
...(process.env as Record<string, string>),
|
||||
HOME: ctx.baseDir,
|
||||
PULSE_BASE_DIR: ctx.upulseDir,
|
||||
};
|
||||
@@ -94,7 +98,9 @@ export function runUpulse(ctx: E2EContext, args: string, options?: RunOptions):
|
||||
});
|
||||
|
||||
if (options?.expectFail) {
|
||||
throw new Error(`Expected command to fail but it succeeded: upulse ${args}\nstdout: ${result}`);
|
||||
throw new Error(
|
||||
`Expected command to fail but it succeeded: upulse ${args}\nstdout: ${result}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
@@ -140,5 +146,5 @@ export function queryVitalsDb(ctx: E2EContext, sql: string): unknown[] {
|
||||
*/
|
||||
export function listObjects(ctx: E2EContext): string[] {
|
||||
if (!existsSync(ctx.objectsDir)) return [];
|
||||
return readdirSync(ctx.objectsDir).filter(f => f.endsWith('.json'));
|
||||
return readdirSync(ctx.objectsDir).filter((f) => f.endsWith('.json'));
|
||||
}
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
* Run: bun test src/e2e/t1-init-daemon-tick.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
runUpulse,
|
||||
queryEventsDb,
|
||||
queryVitalsDb,
|
||||
listObjects,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
listObjects,
|
||||
queryEventsDb,
|
||||
runUpulse,
|
||||
} from './helper.js';
|
||||
|
||||
describe('E2E T1: Init → Daemon → Tick', () => {
|
||||
@@ -81,9 +80,7 @@ describe('E2E T1: Init → Daemon → Tick', () => {
|
||||
});
|
||||
|
||||
it('init installs node_modules in engine', () => {
|
||||
expect(
|
||||
existsSync(join(ctx.engineDir, 'node_modules')),
|
||||
).toBeTruthy();
|
||||
expect(existsSync(join(ctx.engineDir, 'node_modules'))).toBeTruthy();
|
||||
expect(
|
||||
existsSync(join(ctx.engineDir, 'node_modules', '@uncaged', 'pulse')),
|
||||
).toBeTruthy();
|
||||
@@ -107,7 +104,9 @@ describe('E2E T1: Init → Daemon → Tick', () => {
|
||||
const output = runUpulse(ctx, 'tick');
|
||||
|
||||
// tick should produce snapshot output
|
||||
expect(output.includes('Snapshot') || output.includes('Result')).toBeTruthy();
|
||||
expect(
|
||||
output.includes('Snapshot') || output.includes('Result'),
|
||||
).toBeTruthy();
|
||||
|
||||
// Verify events.db has entries
|
||||
expect(existsSync(ctx.eventsDbPath)).toBeTruthy();
|
||||
@@ -122,14 +121,17 @@ describe('E2E T1: Init → Daemon → Tick', () => {
|
||||
});
|
||||
|
||||
it('tick records collect events', () => {
|
||||
const collectEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'collect'") as Array<{
|
||||
const collectEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'collect'",
|
||||
) as Array<{
|
||||
kind: string;
|
||||
key: string;
|
||||
hash: string;
|
||||
}>;
|
||||
expect(collectEvents.length > 0).toBeTruthy();
|
||||
// Should have collected 'system' sense
|
||||
const systemCollects = collectEvents.filter(e => e.key === 'system');
|
||||
const systemCollects = collectEvents.filter((e) => e.key === 'system');
|
||||
expect(systemCollects.length > 0).toBeTruthy();
|
||||
// Each collect event should have a hash pointing to an object
|
||||
for (const evt of systemCollects) {
|
||||
@@ -141,8 +143,6 @@ describe('E2E T1: Init → Daemon → Tick', () => {
|
||||
|
||||
it('upulse daemon status shows STOPPED when daemon is not running', () => {
|
||||
const output = runUpulse(ctx, 'daemon status');
|
||||
expect(
|
||||
output.includes('STOPPED'),
|
||||
).toBeTruthy();
|
||||
expect(output.includes('STOPPED')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
* Run: bun test src/e2e/t2-staging-promote.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
runUpulse,
|
||||
queryEventsDb,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
queryEventsDb,
|
||||
runUpulse,
|
||||
} from './helper.js';
|
||||
|
||||
describe('E2E T2: Staging → Promote', () => {
|
||||
@@ -54,7 +54,7 @@ export default rule;`;
|
||||
|
||||
const ruleFilePath = join(ctx.stagingDir, 'rules', '02-log-hello.ts');
|
||||
writeFileSync(ruleFilePath, newRuleContent);
|
||||
|
||||
|
||||
expect(existsSync(ruleFilePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -70,20 +70,21 @@ export default rule;`;
|
||||
expect(log.includes('02-log-hello')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('upulse deploy promote succeeds', async () => {
|
||||
it('upulse deploy promote succeeds', async () => {
|
||||
const output = runUpulse(ctx, 'deploy promote', { timeout: 30_000 });
|
||||
expect(
|
||||
!output.includes('error') && !output.includes('Error'),
|
||||
).toBeTruthy();
|
||||
expect(!output.includes('error') && !output.includes('Error')).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
it('promote creates promote event in events.db', () => {
|
||||
const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{
|
||||
const promoteEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'promote'",
|
||||
) as Array<{
|
||||
kind: string;
|
||||
code_rev: string;
|
||||
}>;
|
||||
expect(promoteEvents.length > 0).toBeTruthy();
|
||||
|
||||
|
||||
const latestPromote = promoteEvents[promoteEvents.length - 1];
|
||||
expect(latestPromote.code_rev).toBeTruthy();
|
||||
});
|
||||
@@ -104,4 +105,4 @@ export default rule;`;
|
||||
output.includes('log') || output.includes('hello from v2'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
* Run: bun test src/e2e/t3-promote-guard.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
runUpulse,
|
||||
queryEventsDb,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
queryEventsDb,
|
||||
runUpulse,
|
||||
} from './helper.js';
|
||||
|
||||
describe('E2E T3: Promote Guard (Compilation Failed)', () => {
|
||||
@@ -36,7 +36,10 @@ describe('E2E T3: Promote Guard (Compilation Failed)', () => {
|
||||
|
||||
it('upulse init initializes project', () => {
|
||||
const output = runUpulse(ctx, 'init');
|
||||
expect(output.includes('initialized'), `init output should mention initialized, got:\n${output}`).toBeTruthy();
|
||||
expect(
|
||||
output.includes('initialized'),
|
||||
`init output should mention initialized, got:\n${output}`,
|
||||
).toBeTruthy();
|
||||
expect(existsSync(ctx.upulseDir), '.upulse/ should exist').toBeTruthy();
|
||||
expect(existsSync(ctx.stagingDir), 'staging/ should exist').toBeTruthy();
|
||||
expect(existsSync(ctx.engineDir), 'engine/ should exist').toBeTruthy();
|
||||
@@ -49,48 +52,74 @@ export default bad;`;
|
||||
|
||||
const badRuleFilePath = join(ctx.stagingDir, 'rules', '99-bad-rule.ts');
|
||||
writeFileSync(badRuleFilePath, badRuleContent);
|
||||
|
||||
expect(existsSync(badRuleFilePath), '99-bad-rule.ts should be written to staging/rules/').toBeTruthy();
|
||||
|
||||
expect(
|
||||
existsSync(badRuleFilePath),
|
||||
'99-bad-rule.ts should be written to staging/rules/',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commit bad rule in staging', () => {
|
||||
execSync('git add .', { cwd: ctx.stagingDir });
|
||||
execSync('git commit -m "Add bad rule with compilation error"', { cwd: ctx.stagingDir });
|
||||
execSync('git commit -m "Add bad rule with compilation error"', {
|
||||
cwd: ctx.stagingDir,
|
||||
});
|
||||
|
||||
// Verify commit exists
|
||||
const log = execSync('git log --oneline -n 1', {
|
||||
cwd: ctx.stagingDir,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
expect(log.includes('bad rule'), 'commit should mention the bad rule').toBeTruthy();
|
||||
expect(
|
||||
log.includes('bad rule'),
|
||||
'commit should mention the bad rule',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('upulse deploy promote fails due to compilation error', () => {
|
||||
const output = runUpulse(ctx, 'deploy promote', { expectFail: true });
|
||||
|
||||
|
||||
// Check that output contains type-check-related error messages
|
||||
expect(
|
||||
output.includes('Type check') || output.includes('tsc') || output.includes('type check failed'),
|
||||
output.includes('Type check') ||
|
||||
output.includes('tsc') ||
|
||||
output.includes('type check failed'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('no promote event created in events.db after failed promote', () => {
|
||||
const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'");
|
||||
const promoteEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'promote'",
|
||||
);
|
||||
expect(promoteEvents.length).toBe(0);
|
||||
});
|
||||
|
||||
it('bad rule file not merged to engine directory', () => {
|
||||
const badRulePath = join(ctx.engineDir, 'rules', '99-bad-rule.ts');
|
||||
expect(!existsSync(badRulePath), 'bad rule should not exist in engine/rules/ after failed promote').toBeTruthy();
|
||||
expect(
|
||||
!existsSync(badRulePath),
|
||||
'bad rule should not exist in engine/rules/ after failed promote',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('engine directory remains clean after failed promote', () => {
|
||||
// Verify engine still has only the original files (no contamination from failed promote)
|
||||
const engineRulesPath = join(ctx.engineDir, 'rules', '01-collect-system.ts');
|
||||
expect(existsSync(engineRulesPath), 'original rules should still exist in engine').toBeTruthy();
|
||||
|
||||
const engineRulesPath = join(
|
||||
ctx.engineDir,
|
||||
'rules',
|
||||
'01-collect-system.ts',
|
||||
);
|
||||
expect(
|
||||
existsSync(engineRulesPath),
|
||||
'original rules should still exist in engine',
|
||||
).toBeTruthy();
|
||||
|
||||
// But the bad rule should not be there
|
||||
const badRulePath = join(ctx.engineDir, 'rules', '99-bad-rule.ts');
|
||||
expect(!existsSync(badRulePath), 'bad rule should not contaminate engine directory').toBeTruthy();
|
||||
expect(
|
||||
!existsSync(badRulePath),
|
||||
'bad rule should not contaminate engine directory',
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,23 +14,26 @@
|
||||
* Run: bun test src/e2e/t4-rollback.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
|
||||
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
runUpulse,
|
||||
queryEventsDb,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
queryEventsDb,
|
||||
runUpulse,
|
||||
} from './helper.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Count events in the DB. */
|
||||
function eventCount(ctx: E2EContext): number {
|
||||
const rows = queryEventsDb(ctx, 'SELECT COUNT(*) as cnt FROM events') as Array<{ cnt: number }>;
|
||||
const rows = queryEventsDb(
|
||||
ctx,
|
||||
'SELECT COUNT(*) as cnt FROM events',
|
||||
) as Array<{ cnt: number }>;
|
||||
return rows.length > 0 ? rows[0]!.cnt : 0;
|
||||
}
|
||||
|
||||
@@ -39,7 +42,11 @@ function eventCount(ctx: E2EContext): number {
|
||||
* Uses targeted `git add` to avoid committing the node_modules symlink
|
||||
* (git .gitignore pattern `node_modules/` does not match symlinks).
|
||||
*/
|
||||
function addRuleToStaging(ctx: E2EContext, filename: string, commitMsg: string): void {
|
||||
function addRuleToStaging(
|
||||
ctx: E2EContext,
|
||||
filename: string,
|
||||
commitMsg: string,
|
||||
): void {
|
||||
const rulesDir = join(ctx.stagingDir, 'rules');
|
||||
if (!existsSync(rulesDir)) mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
@@ -131,15 +138,24 @@ describe('E2E T4: Rollback', () => {
|
||||
|
||||
it('v2 rule (99-v2.ts) exists in engine before rollback', () => {
|
||||
const v2RulePath = join(ctx.engineDir, 'rules', '99-v2.ts');
|
||||
expect(existsSync(v2RulePath), 'engine should have 99-v2.ts after v2 promote').toBeTruthy();
|
||||
expect(
|
||||
existsSync(v2RulePath),
|
||||
'engine should have 99-v2.ts after v2 promote',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('both promote events are in the database', () => {
|
||||
const promotes = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{
|
||||
const promotes = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'promote'",
|
||||
) as Array<{
|
||||
code_rev: string;
|
||||
meta: string;
|
||||
}>;
|
||||
expect(promotes.length >= 2, `should have at least 2 promote events, found ${promotes.length}`).toBeTruthy();
|
||||
expect(
|
||||
promotes.length >= 2,
|
||||
`should have at least 2 promote events, found ${promotes.length}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Rollback ─────────────────────────────────────────────────
|
||||
@@ -149,14 +165,13 @@ describe('E2E T4: Rollback', () => {
|
||||
|
||||
const output = runUpulse(ctx, 'deploy rollback');
|
||||
expect(
|
||||
output.includes('Rollback complete') || output.includes('rollback event written'),
|
||||
output.includes('Rollback complete') ||
|
||||
output.includes('rollback event written'),
|
||||
).toBeTruthy();
|
||||
|
||||
// Event count must increase (append-only)
|
||||
const countAfter = eventCount(ctx);
|
||||
expect(
|
||||
countAfter > countBefore,
|
||||
).toBeTruthy();
|
||||
expect(countAfter > countBefore).toBeTruthy();
|
||||
eventCounts.push(countAfter);
|
||||
});
|
||||
|
||||
@@ -172,9 +187,7 @@ describe('E2E T4: Rollback', () => {
|
||||
expect(rollbackEvent).toBeTruthy();
|
||||
|
||||
// code_rev should point to the target (v1)
|
||||
expect(
|
||||
rollbackEvent.code_rev,
|
||||
).toBe(v1CodeRev);
|
||||
expect(rollbackEvent.code_rev).toBe(v1CodeRev);
|
||||
|
||||
// meta should have from + to
|
||||
const meta = JSON.parse(rollbackEvent.meta) as { from: string; to: string };
|
||||
@@ -188,19 +201,18 @@ describe('E2E T4: Rollback', () => {
|
||||
it('append-only: event count is monotonically increasing', () => {
|
||||
expect(eventCounts.length >= 5).toBeTruthy();
|
||||
for (let i = 1; i < eventCounts.length; i++) {
|
||||
expect(
|
||||
eventCounts[i]! >= eventCounts[i - 1]!,
|
||||
).toBeTruthy();
|
||||
expect(eventCounts[i]! >= eventCounts[i - 1]!).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('all event ULIDs are ordered', () => {
|
||||
const allEvents = queryEventsDb(ctx, 'SELECT id FROM events ORDER BY id ASC') as Array<{ id: string }>;
|
||||
const allEvents = queryEventsDb(
|
||||
ctx,
|
||||
'SELECT id FROM events ORDER BY id ASC',
|
||||
) as Array<{ id: string }>;
|
||||
expect(allEvents.length > 0).toBeTruthy();
|
||||
for (let i = 1; i < allEvents.length; i++) {
|
||||
expect(
|
||||
allEvents[i]!.id > allEvents[i - 1]!.id,
|
||||
).toBeTruthy();
|
||||
expect(allEvents[i]!.id > allEvents[i - 1]!.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,10 +229,13 @@ describe('E2E T4: Rollback', () => {
|
||||
it('v2 events are not deleted (append-only)', () => {
|
||||
// All events that were in the DB before rollback should still be there.
|
||||
// We check that at least the promote events for both versions exist.
|
||||
const allPromotes = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{
|
||||
const allPromotes = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'promote'",
|
||||
) as Array<{
|
||||
code_rev: string;
|
||||
}>;
|
||||
const codeRevs = new Set(allPromotes.map(p => p.code_rev));
|
||||
const codeRevs = new Set(allPromotes.map((p) => p.code_rev));
|
||||
expect(codeRevs.has(v1CodeRev)).toBeTruthy();
|
||||
expect(codeRevs.has(v2CodeRev)).toBeTruthy();
|
||||
});
|
||||
@@ -229,16 +244,12 @@ describe('E2E T4: Rollback', () => {
|
||||
|
||||
it('engine code reverts after rollback (99-v2.ts removed)', () => {
|
||||
const v2RulePath = join(ctx.engineDir, 'rules', '99-v2.ts');
|
||||
expect(
|
||||
!existsSync(v2RulePath),
|
||||
).toBeTruthy();
|
||||
expect(!existsSync(v2RulePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('v1 rule (98-v1.ts) still exists in engine after rollback', () => {
|
||||
const v1RulePath = join(ctx.engineDir, 'rules', '98-v1.ts');
|
||||
expect(
|
||||
existsSync(v1RulePath),
|
||||
).toBeTruthy();
|
||||
expect(existsSync(v1RulePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('engine git log shows revert commit', () => {
|
||||
@@ -246,9 +257,7 @@ describe('E2E T4: Rollback', () => {
|
||||
cwd: ctx.engineDir,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
expect(
|
||||
log.toLowerCase().includes('revert'),
|
||||
).toBeTruthy();
|
||||
expect(log.toLowerCase().includes('revert')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,7 +279,9 @@ describe('E2E T4: Rollback boundary — no previous version', () => {
|
||||
it('rollback with no promote events fails gracefully', () => {
|
||||
const output = runUpulse(ctx, 'deploy rollback', { expectFail: true });
|
||||
expect(
|
||||
output.includes('no promote event') || output.includes('Nothing to roll back') || output.includes('Error'),
|
||||
output.includes('no promote event') ||
|
||||
output.includes('Nothing to roll back') ||
|
||||
output.includes('Error'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,17 +13,16 @@
|
||||
* Run: bun test src/e2e/t5-migrate.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
|
||||
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
runUpulse,
|
||||
queryEventsDb,
|
||||
listObjects,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
queryEventsDb,
|
||||
runUpulse,
|
||||
} from './helper.js';
|
||||
|
||||
describe('E2E T5: Migrate Chain Migration', () => {
|
||||
@@ -52,7 +51,10 @@ describe('E2E T5: Migrate Chain Migration', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
// Verify v1 collect events exist
|
||||
const collectEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'collect' AND key = 'system'") as Array<{
|
||||
const collectEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'collect' AND key = 'system'",
|
||||
) as Array<{
|
||||
kind: string;
|
||||
key: string;
|
||||
hash: string;
|
||||
@@ -95,7 +97,7 @@ export type Effect =
|
||||
|
||||
const typesFilePath = join(ctx.stagingDir, 'types.ts');
|
||||
writeFileSync(typesFilePath, newTypesContent);
|
||||
|
||||
|
||||
expect(existsSync(typesFilePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -119,7 +121,7 @@ export type Effect =
|
||||
|
||||
const migrateFilePath = join(ctx.stagingDir, 'rules', 'migrate.ts');
|
||||
writeFileSync(migrateFilePath, migrateContent);
|
||||
|
||||
|
||||
expect(existsSync(migrateFilePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -142,9 +144,13 @@ export default createRule<Snapshot, Effect, number>(
|
||||
},
|
||||
);`;
|
||||
|
||||
const systemRuleFilePath = join(ctx.stagingDir, 'rules', '01-collect-system.ts');
|
||||
const systemRuleFilePath = join(
|
||||
ctx.stagingDir,
|
||||
'rules',
|
||||
'01-collect-system.ts',
|
||||
);
|
||||
writeFileSync(systemRuleFilePath, newSystemRuleContent);
|
||||
|
||||
|
||||
expect(existsSync(systemRuleFilePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -181,13 +187,15 @@ export async function collectSystem(): Promise<SystemSense> {
|
||||
|
||||
const executorFilePath = join(ctx.stagingDir, 'executors', 'system.ts');
|
||||
writeFileSync(executorFilePath, newExecutorContent);
|
||||
|
||||
|
||||
expect(existsSync(executorFilePath)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commit changes in staging', () => {
|
||||
execSync('git add .', { cwd: ctx.stagingDir });
|
||||
execSync('git commit -m "Migrate to v2 SystemSense format"', { cwd: ctx.stagingDir });
|
||||
execSync('git commit -m "Migrate to v2 SystemSense format"', {
|
||||
cwd: ctx.stagingDir,
|
||||
});
|
||||
|
||||
// Verify commit exists
|
||||
const log = execSync('git log --oneline -n 1', {
|
||||
@@ -199,34 +207,38 @@ export async function collectSystem(): Promise<SystemSense> {
|
||||
|
||||
it('upulse deploy promote succeeds', () => {
|
||||
const output = runUpulse(ctx, 'deploy promote');
|
||||
expect(
|
||||
!output.includes('error') && !output.includes('Error'),
|
||||
).toBeTruthy();
|
||||
expect(!output.includes('error') && !output.includes('Error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('migrate event created in events.db', () => {
|
||||
const migrateEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'") as Array<{
|
||||
const migrateEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'",
|
||||
) as Array<{
|
||||
kind: string;
|
||||
key: string;
|
||||
hash: string;
|
||||
}>;
|
||||
expect(migrateEvents.length > 0).toBeTruthy();
|
||||
|
||||
|
||||
const latestMigrate = migrateEvents[migrateEvents.length - 1];
|
||||
expect(latestMigrate.hash).toBeTruthy();
|
||||
});
|
||||
|
||||
it('migrated object contains new format data', () => {
|
||||
const migrateEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'") as Array<{
|
||||
const migrateEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'",
|
||||
) as Array<{
|
||||
hash: string;
|
||||
}>;
|
||||
expect(migrateEvents.length > 0).toBeTruthy();
|
||||
|
||||
|
||||
const migrateHash = migrateEvents[migrateEvents.length - 1].hash;
|
||||
const objectPath = join(ctx.objectsDir, `${migrateHash}.json`);
|
||||
|
||||
|
||||
expect(existsSync(objectPath)).toBeTruthy();
|
||||
|
||||
|
||||
const objectData = JSON.parse(readFileSync(objectPath, 'utf-8'));
|
||||
// The stored object is the sense value (data.data), not the full snapshot wrapper
|
||||
expect(objectData.memory?.usedPct !== undefined).toBeTruthy();
|
||||
@@ -234,13 +246,16 @@ export async function collectSystem(): Promise<SystemSense> {
|
||||
});
|
||||
|
||||
it('promote event created after migration', () => {
|
||||
const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{
|
||||
const promoteEvents = queryEventsDb(
|
||||
ctx,
|
||||
"SELECT * FROM events WHERE kind = 'promote'",
|
||||
) as Array<{
|
||||
kind: string;
|
||||
code_rev: string;
|
||||
}>;
|
||||
expect(promoteEvents.length > 0).toBeTruthy();
|
||||
|
||||
|
||||
const latestPromote = promoteEvents[promoteEvents.length - 1];
|
||||
expect(latestPromote.code_rev).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
* Run: bun test src/e2e/t6-dir-flag.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
createE2EContext,
|
||||
cleanupE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from './helper.js';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PATH = join(__dirname, '..', 'cli.ts');
|
||||
|
||||
@@ -6,9 +6,16 @@ import { execSync } from 'node:child_process';
|
||||
|
||||
export function gitExec(cwd: string, args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
return execSync(`git ${args}`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? (err as { stderr?: string }).stderr ?? err.message : String(err);
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? ((err as { stderr?: string }).stderr ?? err.message)
|
||||
: String(err);
|
||||
throw new Error(`git ${args} failed in ${cwd}: ${msg}`);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +32,11 @@ export function gitCommit(cwd: string, message: string): void {
|
||||
gitExec(cwd, `commit -m "${message}"`);
|
||||
}
|
||||
|
||||
export function gitWorktreeAdd(repoDir: string, worktreePath: string, branch: string): void {
|
||||
export function gitWorktreeAdd(
|
||||
repoDir: string,
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
): void {
|
||||
gitExec(repoDir, `worktree add ${worktreePath} -b ${branch}`);
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -4,12 +4,12 @@
|
||||
* Creates ~/.upulse/ with engine scaffold, git init, worktree, bun install
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { defaultConfig, saveConfig, type UpulseConfig } from './config.js';
|
||||
import { gitInit, gitAddAll, gitCommit, gitWorktreeAdd } from './git.js';
|
||||
import { join } from 'node:path';
|
||||
import { defaultConfig, saveConfig } from './config.js';
|
||||
import { gitAddAll, gitCommit, gitInit, gitWorktreeAdd } from './git.js';
|
||||
|
||||
export function initUpulse(dir?: string): void {
|
||||
const baseDir = dir ?? join(homedir(), '.upulse');
|
||||
@@ -83,7 +83,9 @@ export function initUpulse(dir?: string): void {
|
||||
execSync('bun install', { cwd: config.engine.path, stdio: 'inherit' });
|
||||
console.log(' ✓ bun install complete');
|
||||
} catch {
|
||||
console.error('Warning: bun install failed. Run manually: cd ~/.upulse/engine && bun install');
|
||||
console.error(
|
||||
'Warning: bun install failed. Run manually: cd ~/.upulse/engine && bun install',
|
||||
);
|
||||
}
|
||||
|
||||
// Symlink node_modules to staging (staging shares engine's deps)
|
||||
@@ -91,7 +93,7 @@ export function initUpulse(dir?: string): void {
|
||||
symlinkSync(
|
||||
join(config.engine.path, 'node_modules'),
|
||||
join(config.staging.path, 'node_modules'),
|
||||
'dir'
|
||||
'dir',
|
||||
);
|
||||
console.log(' ✓ node_modules symlinked to staging');
|
||||
} catch {
|
||||
@@ -136,7 +138,10 @@ function findLocalPulsePath(): string | null {
|
||||
|
||||
// ── File writers ───────────────────────────────────────────────
|
||||
|
||||
function writeEnginePackageJson(enginePath: string, localPulsePath: string | null): void {
|
||||
function writeEnginePackageJson(
|
||||
enginePath: string,
|
||||
localPulsePath: string | null,
|
||||
): void {
|
||||
const pulseDep = localPulsePath ? `file:${localPulsePath}` : 'latest';
|
||||
const pkg = {
|
||||
name: 'pulse-engine',
|
||||
@@ -149,11 +154,15 @@ function writeEnginePackageJson(enginePath: string, localPulsePath: string | nul
|
||||
'@uncaged/pulse': pulseDep,
|
||||
},
|
||||
devDependencies: {
|
||||
'typescript': '^6.0.2',
|
||||
typescript: '^6.0.2',
|
||||
'bun-types': 'latest',
|
||||
},
|
||||
};
|
||||
writeFileSync(join(enginePath, 'package.json'), JSON.stringify(pkg, null, 2), 'utf-8');
|
||||
writeFileSync(
|
||||
join(enginePath, 'package.json'),
|
||||
JSON.stringify(pkg, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function writeEngineTsConfig(enginePath: string): void {
|
||||
@@ -171,7 +180,11 @@ function writeEngineTsConfig(enginePath: string): void {
|
||||
},
|
||||
include: ['*.ts', 'executors/**/*.ts', 'rules/**/*.ts'],
|
||||
};
|
||||
writeFileSync(join(enginePath, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2), 'utf-8');
|
||||
writeFileSync(
|
||||
join(enginePath, 'tsconfig.json'),
|
||||
JSON.stringify(tsconfig, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function writeAgentsMd(enginePath: string): void {
|
||||
@@ -329,7 +342,11 @@ export default createRule<Snapshot, Effect, number>(
|
||||
},
|
||||
);
|
||||
`;
|
||||
writeFileSync(join(enginePath, 'rules', '01-collect-system.ts'), content, 'utf-8');
|
||||
writeFileSync(
|
||||
join(enginePath, 'rules', '01-collect-system.ts'),
|
||||
content,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function writePulseConfig(enginePath: string): void {
|
||||
|
||||
@@ -5,17 +5,25 @@
|
||||
* The CLI uses queryByKind / getRecent to inspect events.
|
||||
*/
|
||||
|
||||
import { createStore, type PulseStore, type EventRecord, type VitalRecord } from '@uncaged/pulse';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
createStore,
|
||||
type EventRecord,
|
||||
type PulseStore,
|
||||
type VitalRecord,
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
export type { PulseStore, EventRecord, VitalRecord };
|
||||
export type { EventRecord, PulseStore, VitalRecord };
|
||||
|
||||
/**
|
||||
* Open existing events.db + vitals.db (+ sibling objects/ dir) for read/write.
|
||||
* Returns null when the events DB does not exist yet.
|
||||
*/
|
||||
export function openStore(eventsDbPath: string, vitalsDbPath: string): PulseStore | null {
|
||||
export function openStore(
|
||||
eventsDbPath: string,
|
||||
vitalsDbPath: string,
|
||||
): PulseStore | null {
|
||||
if (!existsSync(eventsDbPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +34,10 @@ export function openStore(eventsDbPath: string, vitalsDbPath: string): PulseStor
|
||||
/**
|
||||
* Open or create events.db + vitals.db. Always returns a store (creates DB if needed).
|
||||
*/
|
||||
export function openOrCreateStore(eventsDbPath: string, vitalsDbPath: string): PulseStore {
|
||||
export function openOrCreateStore(
|
||||
eventsDbPath: string,
|
||||
vitalsDbPath: string,
|
||||
): PulseStore {
|
||||
const objectsDir = join(dirname(eventsDbPath), 'objects');
|
||||
return createStore({ eventsDbPath, vitalsDbPath, objectsDir });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user