mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-22 11:03:43 +08:00
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
/**
|
|
* Admin live activity bus: in-process listeners + Redis Pub/Sub fan-in.
|
|
*
|
|
* redis.cmd samples stay local (never LPUSH) to avoid Upstash recursion.
|
|
* Important domain events (message / worker / llm) may PUBLISH + optional backlog.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Db } from "@wechat-ai/db";
|
|
import { K } from "@wechat-ai/db";
|
|
|
|
export type StreamLevel = "info" | "warn" | "error";
|
|
|
|
export type StreamEvent = {
|
|
id: string;
|
|
ts: string;
|
|
type: string;
|
|
level?: StreamLevel;
|
|
source?: string;
|
|
summary: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
|
|
export type EmitInput = {
|
|
type: string;
|
|
summary: string;
|
|
level?: StreamLevel;
|
|
source?: string;
|
|
data?: Record<string, unknown>;
|
|
/** Override auto id */
|
|
id?: string;
|
|
/** Override ISO ts */
|
|
ts?: string;
|
|
};
|
|
|
|
export type EmitOpts = {
|
|
/** LPUSH to Redis recent list (default: true for important domain types) */
|
|
persist?: boolean;
|
|
/** PUBLISH to fleet channel (default: true except redis.cmd / stream.*) */
|
|
fleet?: boolean;
|
|
};
|
|
|
|
export type ActivityBusOptions = {
|
|
db: Db;
|
|
source?: string;
|
|
enabled?: boolean;
|
|
/** Max events accepted per second (process-local) */
|
|
maxEps?: number;
|
|
ringSize?: number;
|
|
backlogSize?: number;
|
|
/** Redis cmd sample rate 0..1 */
|
|
redisSample?: number;
|
|
/** Max redis.cmd events per second */
|
|
redisMaxEps?: number;
|
|
};
|
|
|
|
type Listener = (ev: StreamEvent) => void;
|
|
|
|
const IMPORTANT_PREFIXES = ["message.", "worker.", "llm."];
|
|
|
|
function isImportantType(type: string): boolean {
|
|
return IMPORTANT_PREFIXES.some((p) => type.startsWith(p));
|
|
}
|
|
|
|
function shouldFleetDefault(type: string): boolean {
|
|
if (type.startsWith("redis.")) return false;
|
|
if (type.startsWith("stream.")) return false;
|
|
return isImportantType(type);
|
|
}
|
|
|
|
function shouldPersistDefault(type: string): boolean {
|
|
return shouldFleetDefault(type);
|
|
}
|
|
|
|
/** Truncate message body for stream privacy. */
|
|
export function previewText(
|
|
text: string | null | undefined,
|
|
maxChars: number,
|
|
): { preview: string; len: number; truncated: boolean } {
|
|
const raw = text ?? "";
|
|
const len = raw.length;
|
|
if (len <= maxChars) {
|
|
return { preview: raw, len, truncated: false };
|
|
}
|
|
return {
|
|
preview: raw.slice(0, Math.max(0, maxChars)) + "…",
|
|
len,
|
|
truncated: true,
|
|
};
|
|
}
|
|
|
|
/** Redact Redis key for stream display (keep pattern, drop secrets-ish tails). */
|
|
export function redactRedisKey(key: string | undefined | null): string {
|
|
if (!key) return "";
|
|
const s = String(key);
|
|
// session / creds / blob: keep prefix only
|
|
if (/:creds$/i.test(s) || /:blob$/i.test(s) || /:session:/i.test(s)) {
|
|
const parts = s.split(":");
|
|
return parts.slice(0, Math.min(3, parts.length)).join(":") + ":*";
|
|
}
|
|
if (s.length > 96) return s.slice(0, 93) + "…";
|
|
return s;
|
|
}
|
|
|
|
export class ActivityBus {
|
|
private readonly db: Db;
|
|
private readonly source: string;
|
|
/** Admin-editable at runtime — see applyRuntimeOptions(). */
|
|
private enabled: boolean;
|
|
private maxEps: number;
|
|
private readonly ringSize: number;
|
|
private readonly backlogSize: number;
|
|
private redisSample: number;
|
|
private readonly redisMaxEps: number;
|
|
|
|
private ring: StreamEvent[] = [];
|
|
private listeners = new Set<Listener>();
|
|
private sub: ReturnType<Db["redis"]["duplicate"]> | null = null;
|
|
private started = false;
|
|
private closed = false;
|
|
|
|
private windowStart = Date.now();
|
|
private windowCount = 0;
|
|
private dropped = 0;
|
|
private lastDropReport = 0;
|
|
|
|
private redisWindowStart = Date.now();
|
|
private redisWindowCount = 0;
|
|
|
|
/** Dedup fleet + local echoes (id → expiry ms) */
|
|
private seen = new Map<string, number>();
|
|
private readonly seenTtlMs = 60_000;
|
|
|
|
constructor(opts: ActivityBusOptions) {
|
|
this.db = opts.db;
|
|
this.source = opts.source || "api";
|
|
this.enabled = opts.enabled !== false;
|
|
this.maxEps = Math.max(5, opts.maxEps ?? 80);
|
|
this.ringSize = Math.max(50, opts.ringSize ?? 500);
|
|
this.backlogSize = Math.max(50, opts.backlogSize ?? 300);
|
|
this.redisSample = Math.min(1, Math.max(0, opts.redisSample ?? 0.08));
|
|
this.redisMaxEps = Math.max(1, opts.redisMaxEps ?? 15);
|
|
}
|
|
|
|
/**
|
|
* Apply admin-editable settings in place (runtime settings reload).
|
|
* Turning the stream on lazily opens the Redis subscriber, which start()
|
|
* would otherwise only ever do at boot.
|
|
*/
|
|
applyRuntimeOptions(patch: {
|
|
enabled?: boolean;
|
|
maxEps?: number;
|
|
redisSample?: number;
|
|
}): void {
|
|
if (patch.maxEps !== undefined) this.maxEps = Math.max(5, patch.maxEps);
|
|
if (patch.redisSample !== undefined) {
|
|
this.redisSample = Math.min(1, Math.max(0, patch.redisSample));
|
|
}
|
|
if (patch.enabled === undefined || patch.enabled === this.enabled) return;
|
|
this.enabled = patch.enabled;
|
|
if (this.enabled && !this.started && !this.closed) {
|
|
void this.start().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
isEnabled(): boolean {
|
|
return this.enabled && !this.closed;
|
|
}
|
|
|
|
getSource(): string {
|
|
return this.source;
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (!this.enabled || this.started || this.closed) return;
|
|
this.started = true;
|
|
try {
|
|
const sub = this.db.redis.duplicate();
|
|
this.sub = sub;
|
|
sub.on("error", (err: Error) => {
|
|
if (process.env.LOG_LEVEL === "debug") {
|
|
console.error("[stream] sub error", err.message);
|
|
}
|
|
});
|
|
await sub.subscribe(K.streamChannel);
|
|
sub.on("message", (_ch: string, raw: string) => {
|
|
try {
|
|
const ev = JSON.parse(raw) as StreamEvent;
|
|
if (!ev?.id || !ev?.type) return;
|
|
this.ingestRemote(ev);
|
|
} catch {
|
|
/* ignore bad payload */
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.warn(
|
|
"[stream] fleet subscribe unavailable:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
}
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.closed = true;
|
|
this.listeners.clear();
|
|
if (this.sub) {
|
|
try {
|
|
await this.sub.unsubscribe(K.streamChannel);
|
|
this.sub.disconnect();
|
|
} catch {
|
|
/* */
|
|
}
|
|
this.sub = null;
|
|
}
|
|
}
|
|
|
|
subscribe(fn: Listener): () => void {
|
|
this.listeners.add(fn);
|
|
return () => {
|
|
this.listeners.delete(fn);
|
|
};
|
|
}
|
|
|
|
/** Newest-first local ring snapshot. */
|
|
recentLocal(limit = 100): StreamEvent[] {
|
|
const n = Math.max(1, Math.min(limit, this.ring.length));
|
|
return this.ring.slice(0, n);
|
|
}
|
|
|
|
async recentMerged(limit = 100): Promise<StreamEvent[]> {
|
|
const n = Math.max(1, Math.min(limit, 300));
|
|
let remote: StreamEvent[] = [];
|
|
try {
|
|
const raw = await this.db.redis.lrange(K.streamRecent, 0, n - 1);
|
|
remote = raw
|
|
.map((r) => {
|
|
try {
|
|
return JSON.parse(r) as StreamEvent;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((x): x is StreamEvent => !!x?.id);
|
|
} catch {
|
|
remote = [];
|
|
}
|
|
const local = this.recentLocal(n);
|
|
const map = new Map<string, StreamEvent>();
|
|
for (const ev of [...remote, ...local]) {
|
|
if (!map.has(ev.id)) map.set(ev.id, ev);
|
|
}
|
|
return [...map.values()]
|
|
.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))
|
|
.slice(0, n);
|
|
}
|
|
|
|
/**
|
|
* Emit a stream event. Rate-limited; may drop under load.
|
|
*/
|
|
emit(input: EmitInput, opts: EmitOpts = {}): StreamEvent | null {
|
|
if (!this.isEnabled()) return null;
|
|
|
|
const now = Date.now();
|
|
if (now - this.windowStart >= 1000) {
|
|
this.windowStart = now;
|
|
this.windowCount = 0;
|
|
}
|
|
if (this.windowCount >= this.maxEps) {
|
|
this.dropped++;
|
|
this.maybeReportDrops(now);
|
|
return null;
|
|
}
|
|
this.windowCount++;
|
|
|
|
const ev: StreamEvent = {
|
|
id: input.id || `sev_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
|
ts: input.ts || new Date().toISOString(),
|
|
type: input.type,
|
|
level: input.level || "info",
|
|
source: input.source || this.source,
|
|
summary: input.summary,
|
|
data: input.data,
|
|
};
|
|
|
|
this.deliverLocal(ev);
|
|
|
|
const fleet = opts.fleet ?? shouldFleetDefault(ev.type);
|
|
const persist = opts.persist ?? shouldPersistDefault(ev.type);
|
|
|
|
if (fleet || persist) {
|
|
void this.fanOut(ev, { fleet, persist });
|
|
}
|
|
|
|
return ev;
|
|
}
|
|
|
|
/** Sampled redis command hook (local only). */
|
|
noteRedisCmd(info: {
|
|
op: string;
|
|
key?: string;
|
|
keys?: number;
|
|
ms?: number;
|
|
ok?: boolean;
|
|
}): void {
|
|
if (!this.isEnabled() || this.redisSample <= 0) return;
|
|
|
|
const now = Date.now();
|
|
if (now - this.redisWindowStart >= 1000) {
|
|
this.redisWindowStart = now;
|
|
this.redisWindowCount = 0;
|
|
}
|
|
if (this.redisWindowCount >= this.redisMaxEps) return;
|
|
if (Math.random() > this.redisSample) return;
|
|
this.redisWindowCount++;
|
|
|
|
const keyLabel = info.key
|
|
? redactRedisKey(info.key)
|
|
: info.keys != null
|
|
? `${info.keys} key(s)`
|
|
: "";
|
|
const msPart = info.ms != null ? ` ${Math.round(info.ms)}ms` : "";
|
|
const okPart = info.ok === false ? " fail" : "";
|
|
this.emit(
|
|
{
|
|
type: "redis.cmd",
|
|
level: info.ok === false ? "warn" : "info",
|
|
summary: `${info.op.toUpperCase()}${keyLabel ? " " + keyLabel : ""}${msPart}${okPart}`,
|
|
data: {
|
|
op: info.op,
|
|
key: keyLabel || undefined,
|
|
keys: info.keys,
|
|
ms: info.ms,
|
|
ok: info.ok !== false,
|
|
},
|
|
},
|
|
{ fleet: false, persist: false },
|
|
);
|
|
}
|
|
|
|
private maybeReportDrops(now: number): void {
|
|
if (this.dropped <= 0) return;
|
|
if (now - this.lastDropReport < 5000) return;
|
|
const n = this.dropped;
|
|
this.dropped = 0;
|
|
this.lastDropReport = now;
|
|
// Bypass rate limit for meta by delivering directly
|
|
const ev: StreamEvent = {
|
|
id: `sev_drop_${now.toString(36)}`,
|
|
ts: new Date().toISOString(),
|
|
type: "stream.dropped",
|
|
level: "warn",
|
|
source: this.source,
|
|
summary: `rate limit: dropped ${n} event(s) in last window`,
|
|
data: { dropped: n, maxEps: this.maxEps },
|
|
};
|
|
this.deliverLocal(ev);
|
|
}
|
|
|
|
private ingestRemote(ev: StreamEvent): void {
|
|
if (this.markSeen(ev.id)) return;
|
|
// Do not re-publish remote events
|
|
this.pushRing(ev);
|
|
for (const fn of this.listeners) {
|
|
try {
|
|
fn(ev);
|
|
} catch {
|
|
/* listener errors must not break bus */
|
|
}
|
|
}
|
|
}
|
|
|
|
private deliverLocal(ev: StreamEvent): void {
|
|
if (this.markSeen(ev.id)) return;
|
|
this.pushRing(ev);
|
|
for (const fn of this.listeners) {
|
|
try {
|
|
fn(ev);
|
|
} catch {
|
|
/* */
|
|
}
|
|
}
|
|
}
|
|
|
|
private markSeen(id: string): boolean {
|
|
const now = Date.now();
|
|
if (this.seen.size > 4000) {
|
|
for (const [k, exp] of this.seen) {
|
|
if (exp < now) this.seen.delete(k);
|
|
}
|
|
if (this.seen.size > 4000) {
|
|
// drop oldest ~20%
|
|
let i = 0;
|
|
const n = Math.ceil(this.seen.size * 0.2);
|
|
for (const k of this.seen.keys()) {
|
|
this.seen.delete(k);
|
|
if (++i >= n) break;
|
|
}
|
|
}
|
|
}
|
|
if (this.seen.has(id)) return true;
|
|
this.seen.set(id, now + this.seenTtlMs);
|
|
return false;
|
|
}
|
|
|
|
private pushRing(ev: StreamEvent): void {
|
|
this.ring.unshift(ev);
|
|
if (this.ring.length > this.ringSize) {
|
|
this.ring.length = this.ringSize;
|
|
}
|
|
}
|
|
|
|
private async fanOut(
|
|
ev: StreamEvent,
|
|
opts: { fleet: boolean; persist: boolean },
|
|
): Promise<void> {
|
|
// Never persist/fleet full message bodies — local ring + SSE keep fullText
|
|
const fleetEv = stripSensitiveStreamData(ev);
|
|
const raw = JSON.stringify(fleetEv);
|
|
try {
|
|
if (opts.persist) {
|
|
const pipe = this.db.redis.pipeline();
|
|
pipe.lpush(K.streamRecent, raw);
|
|
pipe.ltrim(K.streamRecent, 0, this.backlogSize - 1);
|
|
if (opts.fleet) pipe.publish(K.streamChannel, raw);
|
|
await pipe.exec();
|
|
} else if (opts.fleet) {
|
|
await this.db.redis.publish(K.streamChannel, raw);
|
|
}
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Drop fullText/text before Redis pub/backlog (privacy + size). */
|
|
function stripSensitiveStreamData(ev: StreamEvent): StreamEvent {
|
|
if (!ev.data) return ev;
|
|
if (!("fullText" in ev.data) && !("text" in ev.data)) return ev;
|
|
const data = { ...ev.data };
|
|
delete data.fullText;
|
|
delete data.text;
|
|
return { ...ev, data };
|
|
}
|
|
|
|
// ── Singleton ──────────────────────────────────────────
|
|
|
|
let bus: ActivityBus | null = null;
|
|
|
|
export function initActivityBus(opts: ActivityBusOptions): ActivityBus {
|
|
if (bus) {
|
|
void bus.stop();
|
|
}
|
|
bus = new ActivityBus(opts);
|
|
return bus;
|
|
}
|
|
|
|
export function getActivityBus(): ActivityBus | null {
|
|
return bus;
|
|
}
|
|
|
|
export function emitActivity(
|
|
input: EmitInput,
|
|
opts?: EmitOpts,
|
|
): StreamEvent | null {
|
|
return bus?.emit(input, opts) ?? null;
|
|
}
|