mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
a0827e5881
commit
a43f34f00c
5 changed files with 218 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
68
apps/daemon/tests/plugins-events-stats.test.ts
Normal file
68
apps/daemon/tests/plugins-events-stats.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue