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:
小橘 🍊
2026-04-14 19:22:54 +08:00
committed by GitHub
parent bd99d3d09c
commit b7d7ae9774
33 changed files with 1357 additions and 578 deletions
+6
View File
@@ -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
View File
@@ -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"
}
}
}
}
+30
View File
@@ -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=="],
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"private": true,
"scripts": {
"lint": "biome check packages/",
"lint:fix": "biome check --write packages/"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11"
}
}
File diff suppressed because it is too large Load Diff
+47 -14
View File
@@ -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';
+58 -12
View File
@@ -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);
});
});
+1 -1
View File
@@ -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];
};
}
+52 -23
View File
@@ -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
View File
@@ -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));
+12 -7
View File
@@ -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);
});
});
});
+9 -6
View File
@@ -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);
+1 -1
View File
@@ -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
+79 -27
View File
@@ -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)}`,
);
}
}
+15 -6
View File
@@ -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.');
+32 -10
View File
@@ -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.');
}
+1 -1
View File
@@ -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
+30 -9
View File
@@ -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)`);
}
+8 -6
View File
@@ -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*$/, '')}`);
}
+44 -14
View File
@@ -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);
}
});
+3 -3
View File
@@ -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;
+7 -3
View File
@@ -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',
);
}
+20 -6
View File
@@ -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 {
+15 -9
View File
@@ -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();
});
});
});
+48 -37
View File
@@ -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();
});
+41 -26
View File
@@ -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 -6
View File
@@ -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');
+14 -3
View File
@@ -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
View File
@@ -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 {
+16 -5
View File
@@ -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 });
}