feat(plugins): od plugin events snapshot/stats + tail filters (Phase 4)

Plan KK1 + KK2 + KK3.

KK1: `od plugin events snapshot` non-SSE one-shot read.
  New GET /api/plugins/events/snapshot returns
  { events, count, generatedAt }. Supports the same ?since=<id>
  trim as the SSE route. Useful for dashboards that don't want
  to hold a long-lived connection.

KK2: `od plugin events stats` rollup helper.
  New summarisePluginEvents(events) pure helper +
  GET /api/plugins/events/stats route. Counts by kind +
  byPluginId (skipping empty plugin ids from marketplace
  events) + oldest/newest timestamps + id range. CLI
  pretty-prints the rollup with sorted-key counts so output
  is byte-deterministic.

KK3: --kind / --plugin-id filter flags on tail / snapshot.
  Both subcommands accept the same filter knobs. tail filters
  client-side post-render (so the SSE backlog still arrives
  but the renderer drops non-matching entries); snapshot does
  the same client-side filter on the JSON response. Lets ops
  write 'show me only plugin.trust-changed for slack-bot' as
  a one-liner.

CLI summary: helpers in cli.ts now reuse formatCounts /
formatTimestamp from §3.GG1 for deterministic output across
status-style commands.

Daemon tests: 1803 \u2192 1808 (+5 cases on plugins-events-stats:
zero-shape on empty buffer, byKind aggregation, byPluginId
skipping empty ids, oldest+newest+id-range, roll-up over a
pre-filtered slice respects the input order).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 18:10:50 +00:00
parent a0827e5881
commit a43f34f00c
No known key found for this signature in database
5 changed files with 218 additions and 17 deletions

View file

@ -1760,30 +1760,95 @@ async function runPluginEvents(rest) {
const sub = rest[0];
if (!sub || sub === 'help' || rest.includes('--help') || rest.includes('-h')) {
console.log(`Usage:
od plugin events tail [-f] [--since <id>] [--json] [--daemon-url <url>]
od plugin events tail [-f] [--since <id>] [--kind <k>] [--plugin-id <id>] [--json]
od plugin events snapshot [--since <id>] [--kind <k>] [--plugin-id <id>] [--json]
od plugin events stats [--json]
Tails the daemon's in-memory plugin event ring buffer:
- 'plugin.installed' when od plugin install / upgrade succeeds
- 'plugin.uninstalled' when od plugin uninstall succeeds
- other lifecycle events (apply / snapshot-prune) as they wire in
Tail / snapshot / stats over the daemon's in-memory plugin event
ring buffer (capped at 1000 entries; resets on daemon restart).
Lifecycle vocabulary:
plugin.installed | plugin.upgraded | plugin.uninstalled
plugin.trust-changed | plugin.snapshot-pruned
plugin.marketplace-refreshed | plugin.applied
Backlog is emitted on connect so a tail consumer doesn't miss
events that fired just before they connected. Optional --since
<id> trims the backlog. -f keeps the connection open + prints
new events live.`);
--since <id> Trim backlog to events strictly after id.
--kind <k> Filter to a single kind.
--plugin-id <id> Filter to events touching one plugin id.
-f / --follow tail-only: keep the SSE stream open.
--json Emit raw JSON (one event per line on tail,
full report on snapshot/stats).`);
process.exit(sub ? 0 : 2);
}
const flags = parseFlags(rest.slice(1), {
string: new Set([...PLUGIN_STRING_FLAGS, 'since', 'kind', 'plugin-id']),
boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']),
});
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const since = typeof flags.since === 'string' ? Number(flags.since) : 0;
const kindFilter = typeof flags.kind === 'string' && flags.kind.length > 0 ? flags.kind : null;
const pluginIdFilter = typeof flags['plugin-id'] === 'string' && flags['plugin-id'].length > 0
? flags['plugin-id']
: null;
const matches = (ev) => {
if (!ev) return false;
if (kindFilter && ev.kind !== kindFilter) return false;
if (pluginIdFilter && ev.pluginId !== pluginIdFilter) return false;
return true;
};
if (sub === 'snapshot') {
const url = `${base}/api/plugins/events/snapshot${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`;
const resp = await fetch(url);
if (!resp.ok) {
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
process.exit(1);
}
const data = await resp.json();
const events = (Array.isArray(data?.events) ? data.events : []).filter(matches);
if (flags.json) {
process.stdout.write(JSON.stringify({ events, count: events.length, generatedAt: data?.generatedAt }, null, 2) + '\n');
return;
}
if (events.length === 0) {
console.log('[events snapshot] no events match filter');
return;
}
for (const ev of events) {
const ts = ev.at ? new Date(ev.at).toISOString() : '?';
const detailKeys = ev.details ? Object.keys(ev.details).slice(0, 3).join(',') : '';
console.log(`#${ev.id} ${ts} ${ev.kind} pluginId=${ev.pluginId || '-'}` +
(detailKeys ? ` details=${detailKeys}` : ''));
}
return;
}
if (sub === 'stats') {
const resp = await fetch(`${base}/api/plugins/events/stats`);
if (!resp.ok) {
console.error(`GET /api/plugins/events/stats failed: ${resp.status} ${await resp.text()}`);
process.exit(1);
}
const data = await resp.json();
if (flags.json) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
return;
}
const s = data?.stats ?? {};
console.log('# Plugin events');
console.log(` total: ${s.total ?? 0}`);
console.log(` by kind: ${formatCounts(s.byKind)}`);
console.log(` by pluginId: ${formatCounts(s.byPluginId)}`);
console.log(` oldest at: ${formatTimestamp(s.oldestAt)}`);
console.log(` newest at: ${formatTimestamp(s.newestAt)}`);
console.log(` id range: ${s.firstId ?? '(none)'} \u2192 ${s.lastId ?? '(none)'}`);
return;
}
if (sub !== 'tail') {
console.error(`unknown subcommand: od plugin events ${sub}`);
process.exit(2);
}
const flags = parseFlags(rest.slice(1), {
string: new Set([...PLUGIN_STRING_FLAGS, 'since']),
boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']),
});
const follow = flags.f === true || flags.follow === true;
const since = typeof flags.since === 'string' ? Number(flags.since) : 0;
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const url = `${base}/api/plugins/events${Number.isFinite(since) && since > 0 ? `?since=${since}` : ''}`;
const resp = await fetch(url, { headers: { accept: 'text/event-stream' } });
if (!resp.ok || !resp.body) {
@ -1794,6 +1859,7 @@ new events live.`);
const decoder = new TextDecoder();
let buffer = '';
const renderEvent = (channel, data) => {
if (!matches(data)) return;
if (flags.json) {
process.stdout.write(JSON.stringify({ channel, ...data }) + '\n');
return;
@ -2918,8 +2984,9 @@ function printPluginHelp() {
(no LLM in the loop).
od plugin verify <pluginId> CI meta-command: doctor + simulate + canon --check
driven by an .od-verify.json config in the plugin folder.
od plugin events tail [-f] [--since N] Tail the in-memory plugin event ring buffer
(install / uninstall / upgrade / etc.).
od plugin events tail [-f] [--kind k] Tail the in-memory plugin event ring buffer.
od plugin events snapshot One-shot read (filterable, no SSE).
od plugin events stats Roll-up: counts by kind / pluginId / time range.
od plugin diff <a> <b> [--json] Compare two installed plugins by id.
od plugin replay <runId> --snapshot-id <id>
Re-emit the immutable snapshot a run launched against.

View file

@ -118,3 +118,47 @@ export function pluginEventBufferSize(): number {
export function __resetPluginEventBufferForTests(): void {
singleton.reset();
}
// 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;
}

View file

@ -61,8 +61,10 @@ export {
pluginEventSnapshot,
subscribePluginEvents,
pluginEventBufferSize,
summarisePluginEvents,
type PluginEvent,
type PluginEventKind,
type PluginEventStats,
} from './events.js';
export * from './atoms/build-test.js';
export * from './atoms/code-import.js';

View file

@ -2115,6 +2115,26 @@ export async function startServer({
}
});
// Plan §3.KK1 — non-SSE one-shot read of the event ring buffer.
// Useful for dashboards + the `od plugin events snapshot` CLI
// command that doesn't need a live tail.
app.get('/api/plugins/events/snapshot', async (req, res) => {
const since = Number(typeof req.query.since === 'string' ? req.query.since : 0);
const { pluginEventSnapshot } = await import('./plugins/events.js');
const events = pluginEventSnapshot(Number.isFinite(since) && since > 0 ? since : 0);
res.json({ events, count: events.length, generatedAt: Date.now() });
});
// Plan §3.KK2 — rolled-up stats over the buffer. Counts by kind +
// pluginId + oldest/newest timestamps + id range.
app.get('/api/plugins/events/stats', async (_req, res) => {
const { pluginEventSnapshot, summarisePluginEvents } = await import('./plugins/events.js');
res.json({
stats: summarisePluginEvents(pluginEventSnapshot()),
generatedAt: Date.now(),
});
});
// Plan §3.II1 — `od plugin events tail`. SSE-backed live event
// stream of plugin lifecycle events from the in-memory ring
// buffer. On open: emits the buffered backlog as 'event: backlog'

View file

@ -0,0 +1,68 @@
// Plan §3.KK2 — summarisePluginEvents() pure roll-up.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
__resetPluginEventBufferForTests,
pluginEventSnapshot,
recordPluginEvent,
summarisePluginEvents,
} from '../src/plugins/events.js';
beforeEach(() => __resetPluginEventBufferForTests());
afterEach(() => __resetPluginEventBufferForTests());
describe('summarisePluginEvents', () => {
it('returns zero-shape for an empty list', () => {
const stats = summarisePluginEvents([]);
expect(stats.total).toBe(0);
expect(stats.byKind).toEqual({});
expect(stats.byPluginId).toEqual({});
expect(stats.oldestAt).toBeNull();
expect(stats.newestAt).toBeNull();
expect(stats.firstId).toBeNull();
expect(stats.lastId).toBeNull();
});
it('counts byKind across the buffer', () => {
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'b' });
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'a' });
const stats = summarisePluginEvents(pluginEventSnapshot());
expect(stats.byKind).toEqual({
'plugin.installed': 2,
'plugin.uninstalled': 1,
});
});
it('counts byPluginId, skipping empty plugin ids (e.g. marketplace events)', () => {
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'b' });
recordPluginEvent({ kind: 'plugin.marketplace-refreshed', pluginId: '' });
const stats = summarisePluginEvents(pluginEventSnapshot());
expect(stats.byPluginId).toEqual({ a: 2, b: 1 });
});
it('records oldestAt + newestAt + id range', () => {
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.upgraded', pluginId: 'a' });
const stats = summarisePluginEvents(pluginEventSnapshot());
expect(stats.firstId).toBe(1);
expect(stats.lastId).toBe(2);
expect(stats.oldestAt).toBeLessThanOrEqual(stats.newestAt!);
expect(stats.total).toBe(2);
});
it('roll-up over a filtered slice respects the input order', () => {
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.uninstalled', pluginId: 'a' });
recordPluginEvent({ kind: 'plugin.installed', pluginId: 'b' });
const onlyA = pluginEventSnapshot().filter((e) => e.pluginId === 'a');
const stats = summarisePluginEvents(onlyA);
expect(stats.total).toBe(2);
expect(stats.byKind).toEqual({
'plugin.installed': 1,
'plugin.uninstalled': 1,
});
});
});