mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan II1.
apps/daemon/src/plugins/events.ts ships a small in-memory FIFO
ring buffer for plugin lifecycle events. Producers call
recordPluginEvent({ kind, pluginId, details? }); consumers either
pull pluginEventSnapshot(since?) for a one-shot read or call
subscribePluginEvents(cb) for a live feed (returns an unsubscribe
callback).
The buffer is capped at 1000 entries — older events fall off the
head. State resets on daemon restart (events survive the run,
not the boot).
PluginEventKind union covers the lifecycle vocabulary:
plugin.installed / .upgraded / .uninstalled
plugin.trust-changed / .applied
plugin.snapshot-pruned / .marketplace-refreshed
Producer hooks:
installer.installFromLocalFolder() emits 'plugin.installed' on
success with { version, sourceKind, source, trust, warnings }.
installer.uninstallPlugin() emits 'plugin.uninstalled' on
successful row removal.
(Apply / snapshot-prune / trust-changed hooks are wiring in
incrementally in subsequent slices; the producer surface is
documented in events.ts.)
apps/daemon/src/server.ts: new GET /api/plugins/events SSE route.
On open: emits the buffered backlog as 'event: backlog' entries,
then forwards every newly-recorded event as 'event: plugin' with
the same shape. Optional ?since=<id> trims the backlog.
CLI: `od plugin events tail [-f] [--since <id>] [--json]`.
Default mode drains the backlog + idles 200ms before exiting;
-f / --follow keeps the connection open and prints live events.
JSON mode emits one event per line for easy piping into jq.
Daemon tests: 1787 \u2192 1797 (+10 cases on plugins-events-buffer:
monotonic id + epoch ms, multi-record id sequence, optional
details field, full snapshot vs. since filter, snapshot copy
semantics, subscriber forwarding, unsubscribe stops forwarding,
listener-exception isolation, 1000-cap eviction).
Co-authored-by: Tom Huang <1043269994@qq.com>
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
// Plan §3.II1 — plugin event ring buffer.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
__resetPluginEventBufferForTests,
|
|
pluginEventBufferSize,
|
|
pluginEventSnapshot,
|
|
recordPluginEvent,
|
|
subscribePluginEvents,
|
|
type PluginEvent,
|
|
} from '../src/plugins/events.js';
|
|
|
|
beforeEach(() => {
|
|
__resetPluginEventBufferForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetPluginEventBufferForTests();
|
|
});
|
|
|
|
describe('recordPluginEvent', () => {
|
|
it('returns the event with monotonic id + epoch ms timestamp', () => {
|
|
const before = Date.now();
|
|
const ev = recordPluginEvent({ kind: 'plugin.installed', pluginId: 'p' });
|
|
const after = Date.now();
|
|
expect(ev.id).toBe(1);
|
|
expect(ev.kind).toBe('plugin.installed');
|
|
expect(ev.pluginId).toBe('p');
|
|
expect(ev.at).toBeGreaterThanOrEqual(before);
|
|
expect(ev.at).toBeLessThanOrEqual(after + 5);
|
|
});
|
|
|
|
it('issues monotonically-increasing ids across multiple events', () => {
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'b' });
|
|
recordPluginEvent({ kind: 'plugin.upgraded', pluginId: 'a' });
|
|
const all = pluginEventSnapshot();
|
|
expect(all.map((e) => e.id)).toEqual([1, 2, 3]);
|
|
expect(all.map((e) => e.kind)).toEqual([
|
|
'plugin.installed', 'plugin.uninstalled', 'plugin.upgraded',
|
|
]);
|
|
});
|
|
|
|
it('attaches details when supplied + omits the field otherwise', () => {
|
|
const a = recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
const b = recordPluginEvent({ kind: 'plugin.installed', pluginId: 'b', details: { version: '1.0.0' } });
|
|
expect(a.details).toBeUndefined();
|
|
expect(b.details).toEqual({ version: '1.0.0' });
|
|
});
|
|
});
|
|
|
|
describe('pluginEventSnapshot', () => {
|
|
it('returns the full buffer when since=0 / omitted', () => {
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'b' });
|
|
expect(pluginEventSnapshot().length).toBe(2);
|
|
expect(pluginEventSnapshot(0).length).toBe(2);
|
|
});
|
|
|
|
it('returns only events strictly after `since`', () => {
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' }); // id=1
|
|
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'b' }); // id=2
|
|
recordPluginEvent({ kind: 'plugin.upgraded', pluginId: 'a' }); // id=3
|
|
const after2 = pluginEventSnapshot(2);
|
|
expect(after2.length).toBe(1);
|
|
expect(after2[0]?.id).toBe(3);
|
|
});
|
|
|
|
it('returns a snapshot copy (mutations on the result do not leak into the buffer)', () => {
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
const snap = pluginEventSnapshot();
|
|
snap.length = 0;
|
|
expect(pluginEventSnapshot().length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('subscribePluginEvents', () => {
|
|
it('forwards every recorded event to subscribers in order', () => {
|
|
const seen: PluginEvent[] = [];
|
|
const unsubscribe = subscribePluginEvents((ev) => { seen.push(ev); });
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'a' });
|
|
expect(seen.map((e) => e.kind)).toEqual([
|
|
'plugin.installed', 'plugin.uninstalled',
|
|
]);
|
|
unsubscribe();
|
|
});
|
|
|
|
it('stops forwarding after unsubscribe', () => {
|
|
const seen: PluginEvent[] = [];
|
|
const off = subscribePluginEvents((ev) => { seen.push(ev); });
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
off();
|
|
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'b' });
|
|
expect(seen.length).toBe(1);
|
|
});
|
|
|
|
it('isolates listener exceptions (a throwing subscriber does not block others)', () => {
|
|
const a: PluginEvent[] = [];
|
|
const b: PluginEvent[] = [];
|
|
const offA = subscribePluginEvents(() => { throw new Error('boom'); });
|
|
const offB = subscribePluginEvents((ev) => { b.push(ev); });
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
|
|
expect(a.length).toBe(0);
|
|
expect(b.length).toBe(1);
|
|
offA();
|
|
offB();
|
|
});
|
|
});
|
|
|
|
describe('ring buffer cap', () => {
|
|
it('evicts oldest entries past 1000', () => {
|
|
for (let i = 0; i < 1100; i++) {
|
|
recordPluginEvent({ kind: 'plugin.installed', pluginId: `p${i}` });
|
|
}
|
|
expect(pluginEventBufferSize()).toBe(1000);
|
|
const snap = pluginEventSnapshot();
|
|
// The first 100 should have been evicted; the buffer's oldest
|
|
// entry should now be id=101.
|
|
expect(snap[0]?.id).toBe(101);
|
|
expect(snap[snap.length - 1]?.id).toBe(1100);
|
|
});
|
|
});
|