aegis-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events.ts — SSE event emitter for session monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Issue #32: Real-time Server-Sent Events for session lifecycle.
|
|
5
|
+
* Subscribers receive events via an EventEmitter pattern.
|
|
6
|
+
* The monitor pushes events; the SSE route consumes them.
|
|
7
|
+
*/
|
|
8
|
+
export interface SessionSSEEvent {
|
|
9
|
+
event: 'status' | 'message' | 'system' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification' | 'permission_denied';
|
|
10
|
+
sessionId: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
/** Issue #87: Unix timestamp (ms) when the event was emitted by Aegis. */
|
|
14
|
+
emittedAt?: number;
|
|
15
|
+
/** Issue #308: Incrementing event ID for Last-Event-ID replay. */
|
|
16
|
+
id?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface VerificationResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
steps: {
|
|
21
|
+
name: 'tsc' | 'build' | 'test';
|
|
22
|
+
ok: boolean;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
output?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}[];
|
|
27
|
+
totalDurationMs: number;
|
|
28
|
+
summary: string;
|
|
29
|
+
}
|
|
30
|
+
export interface GlobalSSEEvent {
|
|
31
|
+
event: 'session_status_change' | 'session_message' | 'session_approval' | 'session_ended' | 'session_created' | 'session_stall' | 'session_dead' | 'session_subagent_start' | 'session_subagent_stop' | 'session_verification';
|
|
32
|
+
sessionId: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
data: Record<string, unknown>;
|
|
35
|
+
/** Issue #301: Incrementing event ID for Last-Event-ID replay. */
|
|
36
|
+
id?: number;
|
|
37
|
+
}
|
|
38
|
+
interface SessionEventBusOptions {
|
|
39
|
+
/** Maximum number of per-session replay buffers retained in memory. */
|
|
40
|
+
maxSessionBuffers?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-session event bus. Subscribers (SSE connections) register here.
|
|
44
|
+
* The monitor calls emit() when events happen.
|
|
45
|
+
*/
|
|
46
|
+
export declare class SessionEventBus {
|
|
47
|
+
private emitters;
|
|
48
|
+
/** #224: Track emitters that are ending so new subscribers get fresh emitters. */
|
|
49
|
+
private readonly endingEmitters;
|
|
50
|
+
/** Global incrementing event ID counter. */
|
|
51
|
+
private nextEventId;
|
|
52
|
+
/** #589: Allocate next event ID with overflow guard. */
|
|
53
|
+
private allocateEventId;
|
|
54
|
+
/** Maximum events to buffer per session for Last-Event-ID replay. */
|
|
55
|
+
private static readonly BUFFER_SIZE;
|
|
56
|
+
/** Per-session ring buffer for event replay. */
|
|
57
|
+
private eventBuffers;
|
|
58
|
+
/** Last activity time per session buffer for LRU eviction. */
|
|
59
|
+
private sessionBufferLastTouched;
|
|
60
|
+
/** Global ring buffer for event replay across all sessions (Issue #301). */
|
|
61
|
+
private globalEventBuffer;
|
|
62
|
+
private readonly maxSessionBuffers;
|
|
63
|
+
constructor(options?: SessionEventBusOptions);
|
|
64
|
+
private touchSessionBuffer;
|
|
65
|
+
private pruneSessionBufferMap;
|
|
66
|
+
/** Get or create the emitter for a session. */
|
|
67
|
+
private getEmitter;
|
|
68
|
+
/** Subscribe to events for a session. Returns unsubscribe function. */
|
|
69
|
+
subscribe(sessionId: string, handler: (event: SessionSSEEvent) => void): () => void;
|
|
70
|
+
/** Emit an event to all subscribers for a session (and global subscribers). */
|
|
71
|
+
emit(sessionId: string, event: SessionSSEEvent): void;
|
|
72
|
+
/** Get events emitted after the given event ID for a session. */
|
|
73
|
+
getEventsSince(sessionId: string, lastEventId: number): SessionSSEEvent[];
|
|
74
|
+
/**
|
|
75
|
+
* Cursor-based replay window for session events.
|
|
76
|
+
*
|
|
77
|
+
* - `beforeId` is an exclusive upper bound on event ID.
|
|
78
|
+
* - If omitted, returns the newest `limit` buffered events.
|
|
79
|
+
* - Returns events in ascending ID order.
|
|
80
|
+
*/
|
|
81
|
+
getEventsBefore(sessionId: string, beforeId?: number, limit?: number): {
|
|
82
|
+
events: SessionSSEEvent[];
|
|
83
|
+
before_id: number | null;
|
|
84
|
+
has_more: boolean;
|
|
85
|
+
oldest_id: number | null;
|
|
86
|
+
newest_id: number | null;
|
|
87
|
+
};
|
|
88
|
+
/** Emit a status change event. */
|
|
89
|
+
emitStatus(sessionId: string, status: string, detail: string): void;
|
|
90
|
+
/** Issue #740: Emit a verification result event. */
|
|
91
|
+
emitVerification(sessionId: string, result: VerificationResult): void;
|
|
92
|
+
/** Emit a message event. */
|
|
93
|
+
emitMessage(sessionId: string, role: string, text: string, contentType?: string, toolMeta?: {
|
|
94
|
+
tool_name?: string;
|
|
95
|
+
tool_id?: string;
|
|
96
|
+
}): void;
|
|
97
|
+
/** Issue #89 L33: Emit a system message event (differentiated from user/assistant messages). */
|
|
98
|
+
emitSystem(sessionId: string, text: string, contentType?: string): void;
|
|
99
|
+
/** Emit an approval request event. */
|
|
100
|
+
emitApproval(sessionId: string, prompt: string): void;
|
|
101
|
+
/** Emit a session ended event. */
|
|
102
|
+
emitEnded(sessionId: string, reason: string): void;
|
|
103
|
+
/** Emit a stall event. */
|
|
104
|
+
emitStall(sessionId: string, stallType: string, detail: string): void;
|
|
105
|
+
/** Emit a dead session event. */
|
|
106
|
+
emitDead(sessionId: string, detail: string): void;
|
|
107
|
+
/** Emit a Claude Code hook event (e.g. Stop, PreToolUse, etc.). */
|
|
108
|
+
emitHook(sessionId: string, hookEvent: string, hookData: Record<string, unknown>): void;
|
|
109
|
+
/** Check if a session has any subscribers. */
|
|
110
|
+
hasSubscribers(sessionId: string): boolean;
|
|
111
|
+
/** Get the number of subscribers for a session. */
|
|
112
|
+
subscriberCount(sessionId: string): number;
|
|
113
|
+
/** Global emitter for aggregating events across all sessions. */
|
|
114
|
+
private globalEmitter;
|
|
115
|
+
/** #689: Pending setImmediate timers for cleanup on destroy. */
|
|
116
|
+
private pendingTimers;
|
|
117
|
+
/** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
|
|
118
|
+
private pendingTimeouts;
|
|
119
|
+
/** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
|
|
120
|
+
subscribeGlobal(handler: (event: GlobalSSEEvent) => void): () => void;
|
|
121
|
+
/** Emit a session created event to global subscribers. */
|
|
122
|
+
emitCreated(sessionId: string, name: string, workDir: string): void;
|
|
123
|
+
/** Get global events emitted after the given event ID (Issue #301). */
|
|
124
|
+
getGlobalEventsSince(lastEventId: number): Array<{
|
|
125
|
+
id: number;
|
|
126
|
+
event: GlobalSSEEvent;
|
|
127
|
+
}>;
|
|
128
|
+
/** #398: Clean up per-session state (call when session is killed). */
|
|
129
|
+
cleanupSession(sessionId: string): void;
|
|
130
|
+
/** Clean up all emitters. */
|
|
131
|
+
destroy(): void;
|
|
132
|
+
}
|
|
133
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events.ts — SSE event emitter for session monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Issue #32: Real-time Server-Sent Events for session lifecycle.
|
|
5
|
+
* Subscribers receive events via an EventEmitter pattern.
|
|
6
|
+
* The monitor pushes events; the SSE route consumes them.
|
|
7
|
+
*/
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { CircularBuffer } from './utils/circular-buffer.js';
|
|
10
|
+
/** Map per-session event types to global event types. */
|
|
11
|
+
function toGlobalEvent(event) {
|
|
12
|
+
const typeMap = {
|
|
13
|
+
status: 'session_status_change',
|
|
14
|
+
message: 'session_message',
|
|
15
|
+
system: 'session_message',
|
|
16
|
+
approval: 'session_approval',
|
|
17
|
+
ended: 'session_ended',
|
|
18
|
+
heartbeat: 'session_status_change',
|
|
19
|
+
verification: 'session_verification',
|
|
20
|
+
stall: 'session_stall',
|
|
21
|
+
dead: 'session_dead',
|
|
22
|
+
subagent_start: 'session_subagent_start',
|
|
23
|
+
subagent_stop: 'session_subagent_stop',
|
|
24
|
+
hook: 'session_message',
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
event: typeMap[event.event] || 'session_status_change',
|
|
28
|
+
sessionId: event.sessionId,
|
|
29
|
+
timestamp: event.timestamp,
|
|
30
|
+
data: event.data,
|
|
31
|
+
id: event.id,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Per-session event bus. Subscribers (SSE connections) register here.
|
|
36
|
+
* The monitor calls emit() when events happen.
|
|
37
|
+
*/
|
|
38
|
+
export class SessionEventBus {
|
|
39
|
+
emitters = new Map();
|
|
40
|
+
/** #224: Track emitters that are ending so new subscribers get fresh emitters. */
|
|
41
|
+
endingEmitters = new WeakSet();
|
|
42
|
+
/** Global incrementing event ID counter. */
|
|
43
|
+
nextEventId = 1;
|
|
44
|
+
/** #589: Allocate next event ID with overflow guard. */
|
|
45
|
+
allocateEventId() {
|
|
46
|
+
if (this.nextEventId >= Number.MAX_SAFE_INTEGER) {
|
|
47
|
+
console.warn('[SessionEventBus] Event ID counter approaching MAX_SAFE_INTEGER, resetting to 1');
|
|
48
|
+
this.nextEventId = 1;
|
|
49
|
+
}
|
|
50
|
+
return this.nextEventId++;
|
|
51
|
+
}
|
|
52
|
+
/** Maximum events to buffer per session for Last-Event-ID replay. */
|
|
53
|
+
static BUFFER_SIZE = 50;
|
|
54
|
+
/** Per-session ring buffer for event replay. */
|
|
55
|
+
eventBuffers = new Map();
|
|
56
|
+
/** Last activity time per session buffer for LRU eviction. */
|
|
57
|
+
sessionBufferLastTouched = new Map();
|
|
58
|
+
/** Global ring buffer for event replay across all sessions (Issue #301). */
|
|
59
|
+
globalEventBuffer = new CircularBuffer(SessionEventBus.BUFFER_SIZE);
|
|
60
|
+
maxSessionBuffers;
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
this.maxSessionBuffers = options.maxSessionBuffers ?? 10_000;
|
|
63
|
+
}
|
|
64
|
+
touchSessionBuffer(sessionId) {
|
|
65
|
+
this.sessionBufferLastTouched.set(sessionId, Date.now());
|
|
66
|
+
}
|
|
67
|
+
pruneSessionBufferMap(protectedSessionId) {
|
|
68
|
+
if (this.eventBuffers.size <= this.maxSessionBuffers)
|
|
69
|
+
return;
|
|
70
|
+
let oldestSessionId;
|
|
71
|
+
let oldestTouched = Infinity;
|
|
72
|
+
for (const [sessionId, touchedAt] of this.sessionBufferLastTouched) {
|
|
73
|
+
if (sessionId === protectedSessionId)
|
|
74
|
+
continue;
|
|
75
|
+
if (!this.eventBuffers.has(sessionId)) {
|
|
76
|
+
this.sessionBufferLastTouched.delete(sessionId);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const emitter = this.emitters.get(sessionId);
|
|
80
|
+
if (emitter && emitter.listenerCount('event') > 0) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (touchedAt < oldestTouched) {
|
|
84
|
+
oldestTouched = touchedAt;
|
|
85
|
+
oldestSessionId = sessionId;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (oldestSessionId !== undefined) {
|
|
89
|
+
this.eventBuffers.delete(oldestSessionId);
|
|
90
|
+
this.sessionBufferLastTouched.delete(oldestSessionId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Get or create the emitter for a session. */
|
|
94
|
+
getEmitter(sessionId) {
|
|
95
|
+
let emitter = this.emitters.get(sessionId);
|
|
96
|
+
// #224: If emitter is ending (session ended), create a fresh one
|
|
97
|
+
if (emitter && this.endingEmitters.has(emitter)) {
|
|
98
|
+
this.emitters.delete(sessionId);
|
|
99
|
+
emitter = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (!emitter) {
|
|
102
|
+
emitter = new EventEmitter();
|
|
103
|
+
emitter.setMaxListeners(50); // Allow many concurrent SSE clients
|
|
104
|
+
this.emitters.set(sessionId, emitter);
|
|
105
|
+
}
|
|
106
|
+
return emitter;
|
|
107
|
+
}
|
|
108
|
+
/** Subscribe to events for a session. Returns unsubscribe function. */
|
|
109
|
+
subscribe(sessionId, handler) {
|
|
110
|
+
const emitter = this.getEmitter(sessionId);
|
|
111
|
+
emitter.on('event', handler);
|
|
112
|
+
return () => {
|
|
113
|
+
emitter.off('event', handler);
|
|
114
|
+
// Clean up emitter if no more listeners and not ending
|
|
115
|
+
if (emitter.listenerCount('event') === 0 && !this.endingEmitters.has(emitter)) {
|
|
116
|
+
this.emitters.delete(sessionId);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Emit an event to all subscribers for a session (and global subscribers). */
|
|
121
|
+
emit(sessionId, event) {
|
|
122
|
+
// Issue #87: Stamp emittedAt for latency measurement
|
|
123
|
+
event.emittedAt = Date.now();
|
|
124
|
+
// Issue #308: Assign incrementing ID for Last-Event-ID replay
|
|
125
|
+
event.id = this.allocateEventId();
|
|
126
|
+
// Push to ring buffer
|
|
127
|
+
let buffer = this.eventBuffers.get(sessionId);
|
|
128
|
+
if (!buffer) {
|
|
129
|
+
buffer = new CircularBuffer(SessionEventBus.BUFFER_SIZE);
|
|
130
|
+
this.eventBuffers.set(sessionId, buffer);
|
|
131
|
+
}
|
|
132
|
+
buffer.push({ id: event.id, event });
|
|
133
|
+
this.touchSessionBuffer(sessionId);
|
|
134
|
+
this.pruneSessionBufferMap(sessionId);
|
|
135
|
+
const emitter = this.emitters.get(sessionId);
|
|
136
|
+
if (emitter) {
|
|
137
|
+
const imm = setImmediate(() => {
|
|
138
|
+
this.pendingTimers.delete(imm);
|
|
139
|
+
emitter.emit('event', event);
|
|
140
|
+
});
|
|
141
|
+
this.pendingTimers.add(imm);
|
|
142
|
+
}
|
|
143
|
+
// Forward to global subscribers
|
|
144
|
+
if (this.globalEmitter) {
|
|
145
|
+
const globalEvent = toGlobalEvent(event);
|
|
146
|
+
// Issue #301: push to global ring buffer
|
|
147
|
+
this.globalEventBuffer.push({ id: event.id, event: globalEvent });
|
|
148
|
+
const imm = setImmediate(() => {
|
|
149
|
+
this.pendingTimers.delete(imm);
|
|
150
|
+
this.globalEmitter?.emit('event', globalEvent);
|
|
151
|
+
});
|
|
152
|
+
this.pendingTimers.add(imm);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Get events emitted after the given event ID for a session. */
|
|
156
|
+
getEventsSince(sessionId, lastEventId) {
|
|
157
|
+
const buffer = this.eventBuffers.get(sessionId);
|
|
158
|
+
if (!buffer)
|
|
159
|
+
return [];
|
|
160
|
+
this.touchSessionBuffer(sessionId);
|
|
161
|
+
return buffer.toArray().filter(e => e.id > lastEventId).map(e => e.event);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Cursor-based replay window for session events.
|
|
165
|
+
*
|
|
166
|
+
* - `beforeId` is an exclusive upper bound on event ID.
|
|
167
|
+
* - If omitted, returns the newest `limit` buffered events.
|
|
168
|
+
* - Returns events in ascending ID order.
|
|
169
|
+
*/
|
|
170
|
+
getEventsBefore(sessionId, beforeId, limit = 50) {
|
|
171
|
+
const buffer = this.eventBuffers.get(sessionId);
|
|
172
|
+
if (!buffer) {
|
|
173
|
+
return {
|
|
174
|
+
events: [],
|
|
175
|
+
before_id: null,
|
|
176
|
+
has_more: false,
|
|
177
|
+
oldest_id: null,
|
|
178
|
+
newest_id: null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
this.touchSessionBuffer(sessionId);
|
|
182
|
+
const entries = buffer.toArray();
|
|
183
|
+
const clampedLimit = Math.min(SessionEventBus.BUFFER_SIZE, Math.max(1, limit));
|
|
184
|
+
const upperExclusive = beforeId !== undefined
|
|
185
|
+
? entries.findIndex(e => e.id >= beforeId)
|
|
186
|
+
: entries.length;
|
|
187
|
+
const resolvedUpperExclusive = upperExclusive === -1 ? entries.length : upperExclusive;
|
|
188
|
+
const lowerInclusive = Math.max(0, resolvedUpperExclusive - clampedLimit);
|
|
189
|
+
const window = entries.slice(lowerInclusive, resolvedUpperExclusive);
|
|
190
|
+
const events = window.map(e => e.event);
|
|
191
|
+
const oldestId = window.length > 0 ? window[0].id : null;
|
|
192
|
+
const newestId = window.length > 0 ? window[window.length - 1].id : null;
|
|
193
|
+
return {
|
|
194
|
+
events,
|
|
195
|
+
before_id: oldestId,
|
|
196
|
+
has_more: lowerInclusive > 0,
|
|
197
|
+
oldest_id: oldestId,
|
|
198
|
+
newest_id: newestId,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Emit a status change event. */
|
|
202
|
+
emitStatus(sessionId, status, detail) {
|
|
203
|
+
this.emit(sessionId, {
|
|
204
|
+
event: 'status',
|
|
205
|
+
sessionId,
|
|
206
|
+
timestamp: new Date().toISOString(),
|
|
207
|
+
data: { status, detail },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** Issue #740: Emit a verification result event. */
|
|
211
|
+
emitVerification(sessionId, result) {
|
|
212
|
+
this.emit(sessionId, {
|
|
213
|
+
event: 'verification',
|
|
214
|
+
sessionId,
|
|
215
|
+
timestamp: new Date().toISOString(),
|
|
216
|
+
data: result,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Emit a message event. */
|
|
220
|
+
emitMessage(sessionId, role, text, contentType, toolMeta) {
|
|
221
|
+
this.emit(sessionId, {
|
|
222
|
+
event: 'message',
|
|
223
|
+
sessionId,
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
data: { role, text, contentType, ...toolMeta },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Issue #89 L33: Emit a system message event (differentiated from user/assistant messages). */
|
|
229
|
+
emitSystem(sessionId, text, contentType) {
|
|
230
|
+
this.emit(sessionId, {
|
|
231
|
+
event: 'system',
|
|
232
|
+
sessionId,
|
|
233
|
+
timestamp: new Date().toISOString(),
|
|
234
|
+
data: { role: 'system', text, contentType, isSystem: true },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Emit an approval request event. */
|
|
238
|
+
emitApproval(sessionId, prompt) {
|
|
239
|
+
this.emit(sessionId, {
|
|
240
|
+
event: 'approval',
|
|
241
|
+
sessionId,
|
|
242
|
+
timestamp: new Date().toISOString(),
|
|
243
|
+
data: { prompt },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/** Emit a session ended event. */
|
|
247
|
+
emitEnded(sessionId, reason) {
|
|
248
|
+
this.emit(sessionId, {
|
|
249
|
+
event: 'ended',
|
|
250
|
+
sessionId,
|
|
251
|
+
timestamp: new Date().toISOString(),
|
|
252
|
+
data: { reason },
|
|
253
|
+
});
|
|
254
|
+
// #224: Mark emitter as ending so new subscribers don't get silently deleted
|
|
255
|
+
const emitter = this.emitters.get(sessionId);
|
|
256
|
+
if (emitter) {
|
|
257
|
+
this.endingEmitters.add(emitter);
|
|
258
|
+
}
|
|
259
|
+
// Clean up after a short delay (let clients receive the event)
|
|
260
|
+
// Capture reference — only delete if it's still the same emitter
|
|
261
|
+
// #357: Also delete the per-session event buffer to prevent unbounded map growth
|
|
262
|
+
// #834: Track the timer so cleanupSession/destroy can cancel it
|
|
263
|
+
const timeout = setTimeout(() => {
|
|
264
|
+
this.pendingTimeouts.delete(timeout);
|
|
265
|
+
if (this.emitters.get(sessionId) === emitter) {
|
|
266
|
+
this.emitters.delete(sessionId);
|
|
267
|
+
}
|
|
268
|
+
this.eventBuffers.delete(sessionId);
|
|
269
|
+
this.sessionBufferLastTouched.delete(sessionId);
|
|
270
|
+
}, 1000);
|
|
271
|
+
this.pendingTimeouts.add(timeout);
|
|
272
|
+
}
|
|
273
|
+
/** Emit a stall event. */
|
|
274
|
+
emitStall(sessionId, stallType, detail) {
|
|
275
|
+
this.emit(sessionId, {
|
|
276
|
+
event: 'stall',
|
|
277
|
+
sessionId,
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
data: { stallType, detail },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Emit a dead session event. */
|
|
283
|
+
emitDead(sessionId, detail) {
|
|
284
|
+
this.emit(sessionId, {
|
|
285
|
+
event: 'dead',
|
|
286
|
+
sessionId,
|
|
287
|
+
timestamp: new Date().toISOString(),
|
|
288
|
+
data: { reason: detail },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Emit a Claude Code hook event (e.g. Stop, PreToolUse, etc.). */
|
|
292
|
+
emitHook(sessionId, hookEvent, hookData) {
|
|
293
|
+
this.emit(sessionId, {
|
|
294
|
+
event: 'hook',
|
|
295
|
+
sessionId,
|
|
296
|
+
timestamp: new Date().toISOString(),
|
|
297
|
+
data: { hookEvent, ...hookData },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/** Check if a session has any subscribers. */
|
|
301
|
+
hasSubscribers(sessionId) {
|
|
302
|
+
const emitter = this.emitters.get(sessionId);
|
|
303
|
+
return !!emitter && emitter.listenerCount('event') > 0;
|
|
304
|
+
}
|
|
305
|
+
/** Get the number of subscribers for a session. */
|
|
306
|
+
subscriberCount(sessionId) {
|
|
307
|
+
const emitter = this.emitters.get(sessionId);
|
|
308
|
+
return emitter ? emitter.listenerCount('event') : 0;
|
|
309
|
+
}
|
|
310
|
+
// ── Global (all-session) SSE ──────────────────────────────────────
|
|
311
|
+
/** Global emitter for aggregating events across all sessions. */
|
|
312
|
+
globalEmitter = null;
|
|
313
|
+
/** #689: Pending setImmediate timers for cleanup on destroy. */
|
|
314
|
+
pendingTimers = new Set();
|
|
315
|
+
/** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
|
|
316
|
+
pendingTimeouts = new Set();
|
|
317
|
+
/** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
|
|
318
|
+
subscribeGlobal(handler) {
|
|
319
|
+
if (!this.globalEmitter) {
|
|
320
|
+
this.globalEmitter = new EventEmitter();
|
|
321
|
+
this.globalEmitter.setMaxListeners(50);
|
|
322
|
+
}
|
|
323
|
+
this.globalEmitter.on('event', handler);
|
|
324
|
+
return () => {
|
|
325
|
+
this.globalEmitter?.off('event', handler);
|
|
326
|
+
// #689: Nullify globalEmitter when all subscribers leave
|
|
327
|
+
if (this.globalEmitter && this.globalEmitter.listenerCount('event') === 0) {
|
|
328
|
+
this.globalEmitter = null;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** Emit a session created event to global subscribers. */
|
|
333
|
+
emitCreated(sessionId, name, workDir) {
|
|
334
|
+
if (!this.globalEmitter)
|
|
335
|
+
return;
|
|
336
|
+
const id = this.allocateEventId();
|
|
337
|
+
const globalEvent = {
|
|
338
|
+
event: 'session_created',
|
|
339
|
+
sessionId,
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
data: { name, workDir },
|
|
342
|
+
id,
|
|
343
|
+
};
|
|
344
|
+
// Issue #301: buffer global-only events
|
|
345
|
+
this.globalEventBuffer.push({ id, event: globalEvent });
|
|
346
|
+
this.globalEmitter.emit('event', globalEvent);
|
|
347
|
+
}
|
|
348
|
+
/** Get global events emitted after the given event ID (Issue #301). */
|
|
349
|
+
getGlobalEventsSince(lastEventId) {
|
|
350
|
+
return this.globalEventBuffer.toArray().filter(e => e.id > lastEventId);
|
|
351
|
+
}
|
|
352
|
+
/** #398: Clean up per-session state (call when session is killed). */
|
|
353
|
+
cleanupSession(sessionId) {
|
|
354
|
+
// #834: Clear pending setTimeout for this session's emitEnded cleanup
|
|
355
|
+
for (const timeout of this.pendingTimeouts) {
|
|
356
|
+
clearTimeout(timeout);
|
|
357
|
+
this.pendingTimeouts.delete(timeout);
|
|
358
|
+
}
|
|
359
|
+
this.eventBuffers.delete(sessionId);
|
|
360
|
+
this.sessionBufferLastTouched.delete(sessionId);
|
|
361
|
+
const emitter = this.emitters.get(sessionId);
|
|
362
|
+
if (emitter) {
|
|
363
|
+
emitter.removeAllListeners();
|
|
364
|
+
this.emitters.delete(sessionId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Clean up all emitters. */
|
|
368
|
+
destroy() {
|
|
369
|
+
// #689: Clear pending setImmediate timers before removing listeners
|
|
370
|
+
for (const imm of this.pendingTimers) {
|
|
371
|
+
clearImmediate(imm);
|
|
372
|
+
}
|
|
373
|
+
this.pendingTimers.clear();
|
|
374
|
+
// #834: Clear pending setTimeout timers
|
|
375
|
+
for (const timeout of this.pendingTimeouts) {
|
|
376
|
+
clearTimeout(timeout);
|
|
377
|
+
}
|
|
378
|
+
this.pendingTimeouts.clear();
|
|
379
|
+
for (const emitter of this.emitters.values()) {
|
|
380
|
+
emitter.removeAllListeners();
|
|
381
|
+
}
|
|
382
|
+
this.emitters.clear();
|
|
383
|
+
this.eventBuffers.clear();
|
|
384
|
+
this.sessionBufferLastTouched.clear();
|
|
385
|
+
this.globalEventBuffer.clear();
|
|
386
|
+
this.globalEmitter?.removeAllListeners();
|
|
387
|
+
this.globalEmitter = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fault-injection.ts — deterministic fault injection harness for integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Disabled by default in production. Enable with AEGIS_FAULT_INJECTION=1
|
|
5
|
+
* or via test helpers.
|
|
6
|
+
*/
|
|
7
|
+
export type FaultMode = 'transient' | 'fatal' | 'delay';
|
|
8
|
+
export interface FaultRule {
|
|
9
|
+
point: string;
|
|
10
|
+
mode: FaultMode;
|
|
11
|
+
every?: number;
|
|
12
|
+
probability?: number;
|
|
13
|
+
delayMs?: number;
|
|
14
|
+
errorMessage?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class InjectedTransientFaultError extends Error {
|
|
17
|
+
readonly point: string;
|
|
18
|
+
constructor(point: string, message?: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class InjectedFatalFaultError extends Error {
|
|
21
|
+
readonly point: string;
|
|
22
|
+
constructor(point: string, message?: string);
|
|
23
|
+
}
|
|
24
|
+
export declare function maybeInjectFault(point: string): Promise<void>;
|
|
25
|
+
export declare function resetFaultInjection(): void;
|
|
26
|
+
export declare function clearFaultRules(): void;
|
|
27
|
+
export declare function setFaultInjectionEnabledForTest(enabled: boolean): void;
|
|
28
|
+
export declare function setFaultInjectionSeedForTest(seed: number): void;
|
|
29
|
+
export declare function addFaultRule(rule: FaultRule): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fault-injection.ts — deterministic fault injection harness for integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Disabled by default in production. Enable with AEGIS_FAULT_INJECTION=1
|
|
5
|
+
* or via test helpers.
|
|
6
|
+
*/
|
|
7
|
+
export class InjectedTransientFaultError extends Error {
|
|
8
|
+
point;
|
|
9
|
+
constructor(point, message) {
|
|
10
|
+
super(message ?? `Injected transient fault at ${point}`);
|
|
11
|
+
this.name = 'InjectedTransientFaultError';
|
|
12
|
+
this.point = point;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class InjectedFatalFaultError extends Error {
|
|
16
|
+
point;
|
|
17
|
+
constructor(point, message) {
|
|
18
|
+
super(message ?? `Injected fatal fault at ${point}`);
|
|
19
|
+
this.name = 'InjectedFatalFaultError';
|
|
20
|
+
this.point = point;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
class FaultInjector {
|
|
24
|
+
rules = [];
|
|
25
|
+
hitCounts = new Map();
|
|
26
|
+
enabledOverride = null;
|
|
27
|
+
seed = 1;
|
|
28
|
+
rngState = 1;
|
|
29
|
+
constructor() {
|
|
30
|
+
this.reset();
|
|
31
|
+
}
|
|
32
|
+
readSeedFromEnv() {
|
|
33
|
+
const parsed = Number.parseInt(process.env.AEGIS_FAULT_SEED ?? '1', 10);
|
|
34
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
|
35
|
+
}
|
|
36
|
+
nextRandom() {
|
|
37
|
+
// LCG constants from Numerical Recipes, deterministic and fast.
|
|
38
|
+
this.rngState = (1664525 * this.rngState + 1013904223) >>> 0;
|
|
39
|
+
return this.rngState / 0x100000000;
|
|
40
|
+
}
|
|
41
|
+
isEnabled() {
|
|
42
|
+
if (this.enabledOverride !== null) {
|
|
43
|
+
return this.enabledOverride;
|
|
44
|
+
}
|
|
45
|
+
return process.env.AEGIS_FAULT_INJECTION === '1';
|
|
46
|
+
}
|
|
47
|
+
reset() {
|
|
48
|
+
this.seed = this.readSeedFromEnv();
|
|
49
|
+
this.rngState = this.seed;
|
|
50
|
+
this.hitCounts.clear();
|
|
51
|
+
}
|
|
52
|
+
clearRules() {
|
|
53
|
+
this.rules.length = 0;
|
|
54
|
+
this.hitCounts.clear();
|
|
55
|
+
}
|
|
56
|
+
setEnabledForTest(enabled) {
|
|
57
|
+
this.enabledOverride = enabled;
|
|
58
|
+
}
|
|
59
|
+
setSeedForTest(seed) {
|
|
60
|
+
this.seed = seed > 0 ? Math.floor(seed) : 1;
|
|
61
|
+
this.rngState = this.seed;
|
|
62
|
+
}
|
|
63
|
+
addRule(rule) {
|
|
64
|
+
this.rules.push(rule);
|
|
65
|
+
}
|
|
66
|
+
async inject(point) {
|
|
67
|
+
if (!this.isEnabled())
|
|
68
|
+
return;
|
|
69
|
+
for (const rule of this.rules) {
|
|
70
|
+
if (rule.point !== point)
|
|
71
|
+
continue;
|
|
72
|
+
const count = (this.hitCounts.get(point) ?? 0) + 1;
|
|
73
|
+
this.hitCounts.set(point, count);
|
|
74
|
+
let shouldTrigger = true;
|
|
75
|
+
if (rule.every && rule.every > 0) {
|
|
76
|
+
shouldTrigger = count % rule.every === 0;
|
|
77
|
+
}
|
|
78
|
+
else if (typeof rule.probability === 'number') {
|
|
79
|
+
shouldTrigger = this.nextRandom() < Math.max(0, Math.min(1, rule.probability));
|
|
80
|
+
}
|
|
81
|
+
if (!shouldTrigger)
|
|
82
|
+
continue;
|
|
83
|
+
if (rule.mode === 'delay') {
|
|
84
|
+
const ms = Math.max(0, rule.delayMs ?? 0);
|
|
85
|
+
if (ms > 0) {
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (rule.mode === 'transient') {
|
|
91
|
+
throw new InjectedTransientFaultError(point, rule.errorMessage);
|
|
92
|
+
}
|
|
93
|
+
throw new InjectedFatalFaultError(point, rule.errorMessage);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const injector = new FaultInjector();
|
|
98
|
+
export function maybeInjectFault(point) {
|
|
99
|
+
return injector.inject(point);
|
|
100
|
+
}
|
|
101
|
+
export function resetFaultInjection() {
|
|
102
|
+
injector.reset();
|
|
103
|
+
}
|
|
104
|
+
export function clearFaultRules() {
|
|
105
|
+
injector.clearRules();
|
|
106
|
+
}
|
|
107
|
+
export function setFaultInjectionEnabledForTest(enabled) {
|
|
108
|
+
injector.setEnabledForTest(enabled);
|
|
109
|
+
}
|
|
110
|
+
export function setFaultInjectionSeedForTest(seed) {
|
|
111
|
+
injector.setSeedForTest(seed);
|
|
112
|
+
}
|
|
113
|
+
export function addFaultRule(rule) {
|
|
114
|
+
injector.addRule(rule);
|
|
115
|
+
}
|