mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan NN1.
apps/daemon/src/plugins/events.ts ships a public buffer reset
distinct from the test-only `__resetPluginEventBufferForTests`:
purgePluginEventBuffer() \u2192 PurgePluginEventBufferResult
{
purged: <count discarded>,
firstId: <id of first discarded entry, or null>,
lastId: <id of last discarded entry, or null>,
preNextId: <buffer's nextId pre-purge, for audit>,
}
The pre-purge stats let an operator confirm what they discarded;
preNextId surfaces 'did we lose a window of events between an
external export and the purge'.
apps/daemon/src/server.ts: new loopback-only POST
/api/plugins/events/purge route (requireLocalDaemonRequest).
CLI: `od plugin events purge --confirm [--json]`. Refuses to
run without --confirm so a stray invocation never drops audit
data accidentally. Output:
[events purge] dropped 47 events (id range: 1 \u2192 47; preNextId=48)
Daemon tests: 1818 \u2192 1822 (+4 cases on plugins-events-purge:
zero-shape on empty buffer, full-buffer purge resets state +
records id range, post-purge nextId restarts at 1, subscribers
are cleared on purge — a new subscriber starts fresh).
Co-authored-by: Tom Huang <1043269994@qq.com>
194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
// Phase 4 / spec §11.5 / plan §3.II1 — plugin event ring buffer.
|
|
//
|
|
// In-memory FIFO ring buffer of plugin lifecycle events
|
|
// (install / uninstall / upgrade / apply / snapshot-prune).
|
|
// Capped at MAX_BUFFER entries to keep daemon memory bounded
|
|
// even on a long-running install spree. Older entries fall off
|
|
// the head when the buffer is full.
|
|
//
|
|
// The buffer is read-only from outside this module. Producers
|
|
// (installer / uninstaller / apply path) call `recordPluginEvent()`;
|
|
// consumers subscribe via `subscribe()` (returns an unsubscribe
|
|
// callback) or pull `snapshot()` for a one-shot read. The route
|
|
// in server.ts wires both into a single SSE endpoint.
|
|
//
|
|
// No SQLite, no FS — pure in-memory state. A daemon restart
|
|
// resets the buffer (events survive the run, not the restart).
|
|
|
|
export type PluginEventKind =
|
|
| 'plugin.installed'
|
|
| 'plugin.upgraded'
|
|
| 'plugin.uninstalled'
|
|
| 'plugin.trust-changed'
|
|
| 'plugin.applied'
|
|
| 'plugin.snapshot-pruned'
|
|
| 'plugin.marketplace-refreshed';
|
|
|
|
export interface PluginEvent {
|
|
// Unique-per-buffer monotonically-increasing id. Resets on
|
|
// daemon restart. Lets a CLI consumer ask 'what's new since
|
|
// event #N?' without re-reading the whole buffer.
|
|
id: number;
|
|
kind: PluginEventKind;
|
|
// Epoch ms.
|
|
at: number;
|
|
// The plugin id this event relates to. Some events
|
|
// (marketplace-refreshed) have no plugin id; they pass
|
|
// pluginId='' so consumers can filter consistently.
|
|
pluginId: string;
|
|
// Optional structured payload — installer ships
|
|
// { source, version }, uninstaller ships { reason }, etc.
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
const MAX_BUFFER = 1000;
|
|
|
|
interface Subscriber {
|
|
(event: PluginEvent): void;
|
|
}
|
|
|
|
class PluginEventBuffer {
|
|
private buffer: PluginEvent[] = [];
|
|
private subscribers = new Set<Subscriber>();
|
|
private nextId = 1;
|
|
|
|
record(input: Omit<PluginEvent, 'id' | 'at'>): PluginEvent {
|
|
const event: PluginEvent = {
|
|
id: this.nextId++,
|
|
at: Date.now(),
|
|
kind: input.kind,
|
|
pluginId: input.pluginId,
|
|
...(input.details ? { details: input.details } : {}),
|
|
};
|
|
this.buffer.push(event);
|
|
if (this.buffer.length > MAX_BUFFER) {
|
|
this.buffer = this.buffer.slice(this.buffer.length - MAX_BUFFER);
|
|
}
|
|
// Fan out to subscribers; exceptions are swallowed so a
|
|
// misbehaving listener can't poison the buffer.
|
|
for (const sub of this.subscribers) {
|
|
try { sub(event); } catch { /* ignore */ }
|
|
}
|
|
return event;
|
|
}
|
|
|
|
// Returns a copy of the current buffer slice (since `since`
|
|
// exclusive). Pass since=0 (or omit) for the whole buffer.
|
|
snapshot(since = 0): PluginEvent[] {
|
|
if (since <= 0) return this.buffer.slice();
|
|
return this.buffer.filter((e) => e.id > since);
|
|
}
|
|
|
|
// Subscribe to live events. Returns an unsubscribe callback.
|
|
subscribe(fn: Subscriber): () => void {
|
|
this.subscribers.add(fn);
|
|
return () => { this.subscribers.delete(fn); };
|
|
}
|
|
|
|
// Test-only reset. Production callers never invoke this.
|
|
reset(): void {
|
|
this.buffer = [];
|
|
this.subscribers.clear();
|
|
this.nextId = 1;
|
|
}
|
|
|
|
size(): number { return this.buffer.length; }
|
|
}
|
|
|
|
const singleton = new PluginEventBuffer();
|
|
|
|
export function recordPluginEvent(input: Omit<PluginEvent, 'id' | 'at'>): PluginEvent {
|
|
return singleton.record(input);
|
|
}
|
|
|
|
export function pluginEventSnapshot(since?: number): PluginEvent[] {
|
|
return singleton.snapshot(since);
|
|
}
|
|
|
|
export function subscribePluginEvents(fn: Subscriber): () => void {
|
|
return singleton.subscribe(fn);
|
|
}
|
|
|
|
export function pluginEventBufferSize(): number {
|
|
return singleton.size();
|
|
}
|
|
|
|
// Test-only helper for vitest (the production path never calls
|
|
// this). Exported so vitest can clear state between cases.
|
|
export function __resetPluginEventBufferForTests(): void {
|
|
singleton.reset();
|
|
}
|
|
|
|
// Plan §3.NN1 — operator-facing buffer reset. Distinct from
|
|
// __resetPluginEventBufferForTests because it returns the
|
|
// pre-purge stats so the caller can audit what was discarded.
|
|
// Exposed via the loopback-only `POST /api/plugins/events/purge`
|
|
// route + `od plugin events purge` CLI subcommand.
|
|
export interface PurgePluginEventBufferResult {
|
|
purged: number;
|
|
// The ids of the first / last entry that was discarded, so an
|
|
// operator who exported the buffer right before the purge can
|
|
// confirm coverage.
|
|
firstId: number | null;
|
|
lastId: number | null;
|
|
// The ringe buffer's nextId value PRE-purge — useful for
|
|
// debugging 'did we lose a window of events between export
|
|
// and purge?'.
|
|
preNextId: number;
|
|
}
|
|
|
|
export function purgePluginEventBuffer(): PurgePluginEventBufferResult {
|
|
const events = singleton.snapshot();
|
|
const result: PurgePluginEventBufferResult = {
|
|
purged: events.length,
|
|
firstId: events.length > 0 ? events[0]!.id : null,
|
|
lastId: events.length > 0 ? events[events.length - 1]!.id : null,
|
|
preNextId: (singleton as unknown as { nextId: number }).nextId,
|
|
};
|
|
singleton.reset();
|
|
return result;
|
|
}
|
|
|
|
// Plan §3.KK2 — pure roll-up over a slice of events. Useful for
|
|
// dashboards + the `od plugin events stats` CLI summary. Lives
|
|
// next to the buffer so consumers can compute the same rollup
|
|
// shape over either the full buffer or a filtered subset.
|
|
|
|
export interface PluginEventStats {
|
|
total: number;
|
|
byKind: Record<string, number>;
|
|
byPluginId: Record<string, number>;
|
|
oldestAt: number | null;
|
|
newestAt: number | null;
|
|
// Ids of the first / last entries so a CLI can echo the range
|
|
// without re-walking the slice.
|
|
firstId: number | null;
|
|
lastId: number | null;
|
|
}
|
|
|
|
export function summarisePluginEvents(events: ReadonlyArray<PluginEvent>): PluginEventStats {
|
|
const stats: PluginEventStats = {
|
|
total: events.length,
|
|
byKind: {},
|
|
byPluginId: {},
|
|
oldestAt: null,
|
|
newestAt: null,
|
|
firstId: null,
|
|
lastId: null,
|
|
};
|
|
for (const ev of events) {
|
|
stats.byKind[ev.kind] = (stats.byKind[ev.kind] ?? 0) + 1;
|
|
if (ev.pluginId) {
|
|
stats.byPluginId[ev.pluginId] = (stats.byPluginId[ev.pluginId] ?? 0) + 1;
|
|
}
|
|
if (typeof ev.at === 'number') {
|
|
stats.oldestAt = stats.oldestAt === null ? ev.at : Math.min(stats.oldestAt, ev.at);
|
|
stats.newestAt = stats.newestAt === null ? ev.at : Math.max(stats.newestAt, ev.at);
|
|
}
|
|
if (typeof ev.id === 'number') {
|
|
stats.firstId = stats.firstId === null ? ev.id : Math.min(stats.firstId, ev.id);
|
|
stats.lastId = stats.lastId === null ? ev.id : Math.max(stats.lastId, ev.id);
|
|
}
|
|
}
|
|
return stats;
|
|
}
|