@graphorin/server 0.6.1 → 0.7.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/CHANGELOG.md +71 -0
- package/README.md +3 -3
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -607
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- package/dist/routes/index.js +0 -12
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events fallback for non-WebSocket-friendly clients.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint is unidirectional: the server pushes
|
|
5
|
+
* `ServerMessage` event frames; control-plane operations
|
|
6
|
+
* (subscribe / unsubscribe / abort / resume) go through REST.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the WS surface, the SSE responder is not multiplexed -
|
|
9
|
+
* one HTTP request per session subject. Reconnection is handled by
|
|
10
|
+
* the standard EventSource `Last-Event-ID` mechanism: when the
|
|
11
|
+
* client supplies the header on resume, the responder walks the
|
|
12
|
+
* dispatcher's replay buffer from that cursor.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
isEventFrame,
|
|
19
|
+
isLifecycleFrame,
|
|
20
|
+
type ServerEventFrame,
|
|
21
|
+
type ServerMessage,
|
|
22
|
+
ServerMessageSchema,
|
|
23
|
+
} from '@graphorin/protocol';
|
|
24
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
25
|
+
import { Hono } from 'hono';
|
|
26
|
+
import { stream } from 'hono/streaming';
|
|
27
|
+
import type { StreamingApi } from 'hono/utils/stream';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createDeliveryCommentarySanitizer,
|
|
31
|
+
type DeliveryCommentaryConfig,
|
|
32
|
+
type DeliveryCommentarySanitizer,
|
|
33
|
+
} from '../commentary/index.js';
|
|
34
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
35
|
+
import { createScopeMiddleware } from '../middleware/scope.js';
|
|
36
|
+
import type { WsDispatcher } from '../ws/dispatcher.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stable shape consumed by {@link createSseRoutes}.
|
|
40
|
+
*
|
|
41
|
+
* @stable
|
|
42
|
+
*/
|
|
43
|
+
export interface SseRoutesDeps {
|
|
44
|
+
/**
|
|
45
|
+
* Cap on the per-connection SSE delivery queue (IP-9). A consumer
|
|
46
|
+
* that stops reading past this many buffered frames is closed
|
|
47
|
+
* instead of growing the queue without bound. Default 1000.
|
|
48
|
+
*/
|
|
49
|
+
readonly perConnectionQueueLimit?: number;
|
|
50
|
+
readonly dispatcher: WsDispatcher;
|
|
51
|
+
readonly commentary?: DeliveryCommentaryConfig;
|
|
52
|
+
/**
|
|
53
|
+
* How long the SSE responder waits between heartbeat comments to
|
|
54
|
+
* keep proxies / load balancers from closing the idle connection.
|
|
55
|
+
* Default `15_000` ms.
|
|
56
|
+
*/
|
|
57
|
+
readonly keepAliveMs?: number;
|
|
58
|
+
readonly now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the SSE event-stream router. Mounts
|
|
63
|
+
* `GET /sessions/:id/events` (the canonical path documented in the
|
|
64
|
+
* runtime spec); operators that want a different path should mount
|
|
65
|
+
* the router under a custom prefix.
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export function createSseRoutes(deps: SseRoutesDeps): Hono<{ Variables: ServerVariables }> {
|
|
70
|
+
const sanitizer: DeliveryCommentarySanitizer =
|
|
71
|
+
deps.dispatcher.sanitizer.applyToEvents.length > 0
|
|
72
|
+
? deps.dispatcher.sanitizer
|
|
73
|
+
: createDeliveryCommentarySanitizer(deps.commentary);
|
|
74
|
+
const keepAliveMs = deps.keepAliveMs ?? 15_000;
|
|
75
|
+
|
|
76
|
+
const app = new Hono<{ Variables: ServerVariables }>();
|
|
77
|
+
app.get(
|
|
78
|
+
'/:id/events',
|
|
79
|
+
// W-107: per-resource, mirroring the WS subject gate - a token
|
|
80
|
+
// scoped to one session streams it over the browser SSE fallback.
|
|
81
|
+
createScopeMiddleware((_path, params) => `sessions:read:${params.id}`),
|
|
82
|
+
sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1000),
|
|
83
|
+
);
|
|
84
|
+
return app;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sseHandler(
|
|
88
|
+
dispatcher: WsDispatcher,
|
|
89
|
+
sanitizer: DeliveryCommentarySanitizer,
|
|
90
|
+
keepAliveMs: number,
|
|
91
|
+
perConnectionQueueLimit: number,
|
|
92
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
93
|
+
return async (c: Context<{ Variables: ServerVariables }>) => {
|
|
94
|
+
const sessionId = c.req.param('id');
|
|
95
|
+
if (sessionId === undefined || sessionId.length === 0) {
|
|
96
|
+
return c.json(
|
|
97
|
+
{ error: 'session-id-required', message: 'Path parameter :id is required.' },
|
|
98
|
+
400,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const subject = `session:${sessionId}/events`;
|
|
102
|
+
const sinceEventId = c.req.header('last-event-id') ?? c.req.header('Last-Event-ID');
|
|
103
|
+
|
|
104
|
+
c.header('Content-Type', 'text/event-stream; charset=utf-8');
|
|
105
|
+
c.header('Cache-Control', 'no-cache, no-transform');
|
|
106
|
+
c.header('Connection', 'keep-alive');
|
|
107
|
+
c.header('X-Accel-Buffering', 'no');
|
|
108
|
+
|
|
109
|
+
return stream(c, async (writer) => {
|
|
110
|
+
const replay = dispatcher.replayBuffer.replay(subject, sinceEventId);
|
|
111
|
+
let lastEventId: string | undefined;
|
|
112
|
+
for (const frame of replay.events) {
|
|
113
|
+
const sanitized = sanitizer.sanitize({ ...frame, subscriptionId: 'sse' }, 'sse');
|
|
114
|
+
await writeFrame(writer as StreamingApi, sanitized);
|
|
115
|
+
lastEventId = sanitized.eventId;
|
|
116
|
+
}
|
|
117
|
+
if (replay.droppedCount > 0) {
|
|
118
|
+
await writeReplayMarker(writer as StreamingApi, replay.droppedCount, lastEventId);
|
|
119
|
+
}
|
|
120
|
+
// Live tail - register a synthetic subscription so the
|
|
121
|
+
// dispatcher fans new events into this connection. The
|
|
122
|
+
// dispatcher already validates + sanitizes every frame; here we
|
|
123
|
+
// just write the bytes onto the wire.
|
|
124
|
+
const subscriberId = `sse-${Math.random().toString(36).slice(2, 10)}`;
|
|
125
|
+
const subscriptionId = `${subscriberId}-sub`;
|
|
126
|
+
const authState = c.get('state').auth;
|
|
127
|
+
// IP-13: the anonymous (auth.kind='none') principal carries `admin:*` in
|
|
128
|
+
// grantedScopes, so the dispatcher's per-subject scope check still passes.
|
|
129
|
+
const grantedScopes =
|
|
130
|
+
authState.kind === 'token' || authState.kind === 'anonymous' ? authState.grantedScopes : [];
|
|
131
|
+
const tokenId =
|
|
132
|
+
authState.kind === 'token'
|
|
133
|
+
? authState.token.tokenId
|
|
134
|
+
: authState.kind === 'anonymous'
|
|
135
|
+
? 'anonymous'
|
|
136
|
+
: 'sse-anon';
|
|
137
|
+
let queue: ServerMessage[] = [];
|
|
138
|
+
let closedByBackpressure = false;
|
|
139
|
+
const queueLimit = perConnectionQueueLimit;
|
|
140
|
+
let resumeNotify: (() => void) | undefined;
|
|
141
|
+
const notify = (): void => {
|
|
142
|
+
if (resumeNotify !== undefined) {
|
|
143
|
+
const fn = resumeNotify;
|
|
144
|
+
resumeNotify = undefined;
|
|
145
|
+
fn();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const handle = {
|
|
149
|
+
id: subscriberId,
|
|
150
|
+
tokenId,
|
|
151
|
+
// The strict-mode subscribe path enforces scope; the outer
|
|
152
|
+
// middleware gates this handler per-resource
|
|
153
|
+
// (`sessions:read:<sessionId>`, W-107) and the dispatcher's
|
|
154
|
+
// per-subject check requires the same grant - the two layers
|
|
155
|
+
// are consistent for session-scoped tokens.
|
|
156
|
+
grantedScopes,
|
|
157
|
+
send: (frame: ServerMessage) => {
|
|
158
|
+
// IP-9: bound the queue - a stalled consumer is closed, not
|
|
159
|
+
// buffered into the heap forever.
|
|
160
|
+
if (queue.length >= queueLimit) {
|
|
161
|
+
closedByBackpressure = true;
|
|
162
|
+
queue = [];
|
|
163
|
+
notify();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
queue.push(frame);
|
|
167
|
+
notify();
|
|
168
|
+
},
|
|
169
|
+
close: () => {
|
|
170
|
+
queue = [];
|
|
171
|
+
notify();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const reg = dispatcher.registerSubscriber(handle);
|
|
175
|
+
const result = dispatcher.subscribe({
|
|
176
|
+
subscriberId,
|
|
177
|
+
subject,
|
|
178
|
+
subscriptionId,
|
|
179
|
+
...(lastEventId !== undefined ? { sinceEventId: lastEventId } : {}),
|
|
180
|
+
});
|
|
181
|
+
if (!result.ok) {
|
|
182
|
+
reg.unregister();
|
|
183
|
+
await writer.write(
|
|
184
|
+
encodeSse({
|
|
185
|
+
event: 'error',
|
|
186
|
+
data: JSON.stringify({
|
|
187
|
+
error: 'sse-subscribe-failed',
|
|
188
|
+
reason: result.reason,
|
|
189
|
+
}),
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
await writer.close();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const heartbeat = setInterval(() => {
|
|
196
|
+
// Fire-and-forget heartbeat comment - clients ignore comment
|
|
197
|
+
// lines (RFC 6455 § 9.1).
|
|
198
|
+
writer.write(': keep-alive\n\n').catch(() => undefined);
|
|
199
|
+
}, keepAliveMs);
|
|
200
|
+
let aborted = false;
|
|
201
|
+
const onAbort = (): void => {
|
|
202
|
+
aborted = true;
|
|
203
|
+
notify();
|
|
204
|
+
};
|
|
205
|
+
c.req.raw.signal.addEventListener('abort', onAbort, { once: true });
|
|
206
|
+
try {
|
|
207
|
+
while (!aborted) {
|
|
208
|
+
if (closedByBackpressure) {
|
|
209
|
+
// IP-9: the consumer stalled past the queue cap - close the
|
|
210
|
+
// stream with a terminal lifecycle frame instead of
|
|
211
|
+
// buffering unboundedly.
|
|
212
|
+
await writer.write(
|
|
213
|
+
encodeSse({
|
|
214
|
+
event: 'lifecycle',
|
|
215
|
+
data: JSON.stringify({
|
|
216
|
+
kind: 'lifecycle',
|
|
217
|
+
subscriptionId,
|
|
218
|
+
status: 'failed',
|
|
219
|
+
reason: 'flow.throttled',
|
|
220
|
+
}),
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
aborted = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (queue.length > 0) {
|
|
227
|
+
const next = queue.shift();
|
|
228
|
+
if (next === undefined) continue;
|
|
229
|
+
if (isEventFrame(next)) {
|
|
230
|
+
await writeFrame(writer as StreamingApi, next);
|
|
231
|
+
} else if (isLifecycleFrame(next)) {
|
|
232
|
+
await writer.write(
|
|
233
|
+
encodeSse({
|
|
234
|
+
event: 'lifecycle',
|
|
235
|
+
data: JSON.stringify(next),
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
if (
|
|
239
|
+
next.status === 'completed' ||
|
|
240
|
+
next.status === 'aborted' ||
|
|
241
|
+
next.status === 'failed'
|
|
242
|
+
) {
|
|
243
|
+
aborted = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
await new Promise<void>((resolve) => {
|
|
249
|
+
resumeNotify = resolve;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
clearInterval(heartbeat);
|
|
254
|
+
c.req.raw.signal.removeEventListener('abort', onAbort);
|
|
255
|
+
dispatcher.unsubscribe(subscriptionId);
|
|
256
|
+
reg.unregister();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeFrame(writer: StreamingApi, frame: ServerEventFrame): Promise<void> {
|
|
263
|
+
const validated = ServerMessageSchema.safeParse(frame);
|
|
264
|
+
if (!validated.success) return;
|
|
265
|
+
await writer.write(
|
|
266
|
+
encodeSse({
|
|
267
|
+
event: frame.type,
|
|
268
|
+
id: frame.eventId,
|
|
269
|
+
data: JSON.stringify(frame),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function writeReplayMarker(
|
|
275
|
+
writer: StreamingApi,
|
|
276
|
+
droppedCount: number,
|
|
277
|
+
lastEventId: string | undefined,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
await writer.write(
|
|
280
|
+
encodeSse({
|
|
281
|
+
...(lastEventId !== undefined ? { id: lastEventId } : {}),
|
|
282
|
+
event: 'replay-marker',
|
|
283
|
+
data: JSON.stringify({
|
|
284
|
+
v: '1',
|
|
285
|
+
kind: 'replay-marker',
|
|
286
|
+
subscriptionId: 'sse',
|
|
287
|
+
eventId: lastEventId ?? `evt-replay-${Date.now().toString(36)}`,
|
|
288
|
+
droppedCount,
|
|
289
|
+
}),
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function encodeSse(frame: { event?: string; id?: string; data: string }): string {
|
|
295
|
+
let out = '';
|
|
296
|
+
if (frame.event !== undefined) out += `event: ${frame.event}\n`;
|
|
297
|
+
if (frame.id !== undefined) out += `id: ${frame.id}\n`;
|
|
298
|
+
for (const line of frame.data.split('\n')) {
|
|
299
|
+
out += `data: ${line}\n`;
|
|
300
|
+
}
|
|
301
|
+
out += '\n';
|
|
302
|
+
return out;
|
|
303
|
+
}
|
package/src/sse/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W-051: bridge the `@graphorin/tools` audit-event bus (`onToolAudit`,
|
|
3
|
+
* which `@graphorin/mcp` also emits into) into the server's
|
|
4
|
+
* tamper-evident audit log. Without this, a shadow-mode
|
|
5
|
+
* `tool:dataflow:flagged` was invisible to an operator unless they
|
|
6
|
+
* hand-wired a subscriber - which no guide showed.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `commentary/audit-bridge.ts`: writes serialise through one
|
|
9
|
+
* promise tail so concurrent events never race `appendAudit`'s seq
|
|
10
|
+
* chain, a failed write is isolated (warn, never throw into the
|
|
11
|
+
* emitter), and `drain()` lets shutdown/tests await in-flight writes.
|
|
12
|
+
*
|
|
13
|
+
* Volume control (`config.audit.toolEvents`):
|
|
14
|
+
* - `'security'` (default): only the security-significant subset -
|
|
15
|
+
* dataflow flagged/blocked/declassified, sanitization hits/blocks,
|
|
16
|
+
* approval lifecycle, collisions, cap-disabled. High-frequency
|
|
17
|
+
* `tool:execute:*` chatter stays OUT of the Merkle chain.
|
|
18
|
+
* - `'all'`: every tool audit event, including per-call execute
|
|
19
|
+
* start/end/error (bounded by call volume - size the chain
|
|
20
|
+
* accordingly).
|
|
21
|
+
* - `'off'`: bridge disabled.
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type AuditDb, type AuditEntryInput, appendAudit } from '@graphorin/security/audit';
|
|
27
|
+
import { onToolAudit, type ToolAuditEvent } from '@graphorin/tools/audit';
|
|
28
|
+
|
|
29
|
+
/** Volume policy for the tool-audit bridge. @stable */
|
|
30
|
+
export type ToolEventsAuditPolicy = 'security' | 'all' | 'off';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `'security'` allowlist: decisions an operator reviews, not
|
|
34
|
+
* per-call chatter.
|
|
35
|
+
*/
|
|
36
|
+
const SECURITY_ACTIONS: ReadonlySet<string> = new Set([
|
|
37
|
+
'tool:dataflow:flagged',
|
|
38
|
+
'tool:dataflow:blocked',
|
|
39
|
+
'tool:dataflow:declassified',
|
|
40
|
+
'tool:result:sanitization:hit',
|
|
41
|
+
'tool:result:sanitization:blocked',
|
|
42
|
+
'tool:approval:requested',
|
|
43
|
+
'tool:approval:granted',
|
|
44
|
+
'tool:approval:denied',
|
|
45
|
+
'tool:collision:detected',
|
|
46
|
+
'tool:collision:priority-resolved',
|
|
47
|
+
'tool:collision:auto-prefix-applied',
|
|
48
|
+
'tool:collision:manual-rejected',
|
|
49
|
+
'tool:collision:suppressed',
|
|
50
|
+
'tool:result:cap-disabled',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Translate a tools audit event into an audit-chain entry. */
|
|
54
|
+
export function toolAuditEventToAuditInput(event: ToolAuditEvent): AuditEntryInput {
|
|
55
|
+
const context: AuditEntryInput['context'] = {
|
|
56
|
+
...(event.context?.runId !== undefined ? { runId: event.context.runId } : {}),
|
|
57
|
+
...(event.context?.sessionId !== undefined ? { sessionId: event.context.sessionId } : {}),
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
actor: { kind: event.actor.kind, id: event.actor.id },
|
|
61
|
+
action: event.action,
|
|
62
|
+
target: event.target,
|
|
63
|
+
decision: event.decision,
|
|
64
|
+
ts: event.ts,
|
|
65
|
+
...(Object.keys(context).length > 0 ? { context } : {}),
|
|
66
|
+
metadata: {
|
|
67
|
+
...(event.metadata ?? {}),
|
|
68
|
+
...(event.context?.stepNumber !== undefined ? { stepNumber: event.context.stepNumber } : {}),
|
|
69
|
+
...(event.context?.toolCallId !== undefined ? { toolCallId: event.context.toolCallId } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Handle returned by {@link bridgeToolAuditToAudit}. @stable */
|
|
75
|
+
export interface ToolAuditBridge {
|
|
76
|
+
/** Unsubscribe from the tool-audit bus. */
|
|
77
|
+
readonly stop: () => void;
|
|
78
|
+
/** Resolve once every queued audit write has settled. */
|
|
79
|
+
readonly drain: () => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Subscribe the audit chain to the tools audit bus. Returns a handle
|
|
84
|
+
* whose `stop()` MUST run on shutdown (the bus is process-global).
|
|
85
|
+
*
|
|
86
|
+
* @stable
|
|
87
|
+
*/
|
|
88
|
+
export function bridgeToolAuditToAudit(
|
|
89
|
+
db: AuditDb,
|
|
90
|
+
options: {
|
|
91
|
+
readonly policy?: ToolEventsAuditPolicy;
|
|
92
|
+
readonly onWriteError?: (event: ToolAuditEvent, error: unknown) => void;
|
|
93
|
+
} = {},
|
|
94
|
+
): ToolAuditBridge {
|
|
95
|
+
const policy = options.policy ?? 'security';
|
|
96
|
+
if (policy === 'off') {
|
|
97
|
+
return { stop: () => {}, drain: async () => {} };
|
|
98
|
+
}
|
|
99
|
+
const onWriteError = options.onWriteError ?? defaultOnWriteError;
|
|
100
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
101
|
+
const stop = onToolAudit((event) => {
|
|
102
|
+
if (policy === 'security' && !SECURITY_ACTIONS.has(event.action)) return;
|
|
103
|
+
const input = toolAuditEventToAuditInput(event);
|
|
104
|
+
tail = tail.then(() => appendAudit(db, input)).catch((error) => onWriteError(event, error));
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
stop,
|
|
108
|
+
drain: async () => {
|
|
109
|
+
await tail;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultOnWriteError(event: ToolAuditEvent, error: unknown): void {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[graphorin/server] WARN: failed to write a tool audit entry (${event.action}): ${
|
|
117
|
+
error instanceof Error ? error.message : String(error)
|
|
118
|
+
}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger scheduler integration with the server lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The daemon wraps a `Scheduler` from `@graphorin/triggers` and binds
|
|
5
|
+
* its `start()` / `stop()` calls to the `beforeStart` / `beforeShutdown`
|
|
6
|
+
* lifecycle hooks. On `start()`, every persisted trigger is loaded
|
|
7
|
+
* from `trigger_state` (durable) and the per-trigger `catchupPolicy`
|
|
8
|
+
* is honoured so cron / interval / idle / event triggers survive
|
|
9
|
+
* process restarts as documented in the runtime spec.
|
|
10
|
+
*
|
|
11
|
+
* The daemon never invokes user callbacks itself - those run inside
|
|
12
|
+
* the `Scheduler`. The daemon only owns lifecycle + observability.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CatchupPolicy, Scheduler, SchedulerEvent } from '@graphorin/triggers';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Snapshot exposed via {@link TriggersDaemon.status} + the
|
|
21
|
+
* `/v1/health` aggregator. Counts split by `disabled` so the health
|
|
22
|
+
* endpoint can flag a deployment that disabled every cron trigger.
|
|
23
|
+
*
|
|
24
|
+
* @stable
|
|
25
|
+
*/
|
|
26
|
+
export interface TriggersDaemonStatus {
|
|
27
|
+
readonly running: boolean;
|
|
28
|
+
readonly active: number;
|
|
29
|
+
readonly disabled: number;
|
|
30
|
+
readonly deferred: number;
|
|
31
|
+
/** Last fire timestamp across the entire pool (ISO-8601). */
|
|
32
|
+
readonly lastFireAt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Aggregate counters surfaced through the Prometheus
|
|
37
|
+
* `/v1/metrics` exposition. Updated incrementally as the scheduler
|
|
38
|
+
* publishes lifecycle events.
|
|
39
|
+
*
|
|
40
|
+
* @stable
|
|
41
|
+
*/
|
|
42
|
+
export interface TriggersDaemonMetrics {
|
|
43
|
+
readonly fires: ReadonlyMap<string, { readonly success: number; readonly error: number }>;
|
|
44
|
+
readonly catchupApplied: number;
|
|
45
|
+
readonly libModeWarnings: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @stable
|
|
50
|
+
*/
|
|
51
|
+
export interface CreateTriggersDaemonOptions {
|
|
52
|
+
readonly scheduler: Scheduler;
|
|
53
|
+
/**
|
|
54
|
+
* Optional logger. Defaults to the standard error stream so the
|
|
55
|
+
* daemon never depends on the framework logger directly.
|
|
56
|
+
*/
|
|
57
|
+
readonly warn?: (message: string) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stateful handle returned by {@link createTriggersDaemon}. The
|
|
62
|
+
* `start()` / `stop()` methods are wired into
|
|
63
|
+
* `LifecycleHooks.beforeStart` / `beforeShutdown` by `createServer`.
|
|
64
|
+
*
|
|
65
|
+
* @stable
|
|
66
|
+
*/
|
|
67
|
+
export interface TriggersDaemon {
|
|
68
|
+
start(): Promise<void>;
|
|
69
|
+
stop(): Promise<void>;
|
|
70
|
+
status(): Promise<TriggersDaemonStatus>;
|
|
71
|
+
metrics(): TriggersDaemonMetrics;
|
|
72
|
+
readonly scheduler: Scheduler;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @stable
|
|
77
|
+
*/
|
|
78
|
+
export function createTriggersDaemon(options: CreateTriggersDaemonOptions): TriggersDaemon {
|
|
79
|
+
const fires = new Map<string, { success: number; error: number }>();
|
|
80
|
+
let catchupApplied = 0;
|
|
81
|
+
let libModeWarnings = 0;
|
|
82
|
+
let lastFireAtMs: number | undefined;
|
|
83
|
+
let running = false;
|
|
84
|
+
let pump: Promise<void> | undefined;
|
|
85
|
+
|
|
86
|
+
const warn = options.warn ?? ((m: string) => process.stderr.write(`${m}\n`));
|
|
87
|
+
|
|
88
|
+
function recordFire(id: string, kind: 'success' | 'error'): void {
|
|
89
|
+
const entry = fires.get(id) ?? { success: 0, error: 0 };
|
|
90
|
+
entry[kind] += 1;
|
|
91
|
+
fires.set(id, entry);
|
|
92
|
+
lastFireAtMs = Date.now();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function pumpEvents(): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
for await (const event of options.scheduler.events()) {
|
|
98
|
+
observe(event);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
warn(`[graphorin/server] triggers daemon event pump aborted: ${describeError(err)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function observe(event: SchedulerEvent): void {
|
|
106
|
+
switch (event.type) {
|
|
107
|
+
case 'fire-end':
|
|
108
|
+
recordFire(event.id, 'success');
|
|
109
|
+
return;
|
|
110
|
+
case 'fire-error':
|
|
111
|
+
recordFire(event.id, 'error');
|
|
112
|
+
warn(
|
|
113
|
+
`[graphorin/server] triggers daemon: trigger '${event.id}' failed after ${event.durationMs}ms: ${describeError(event.error)}`,
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
case 'catchup-applied':
|
|
117
|
+
catchupApplied += event.missed;
|
|
118
|
+
return;
|
|
119
|
+
case 'lib-mode-warning':
|
|
120
|
+
libModeWarnings += 1;
|
|
121
|
+
return;
|
|
122
|
+
default:
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
get scheduler() {
|
|
129
|
+
return options.scheduler;
|
|
130
|
+
},
|
|
131
|
+
async start() {
|
|
132
|
+
if (running) return;
|
|
133
|
+
running = true;
|
|
134
|
+
pump = pumpEvents();
|
|
135
|
+
await options.scheduler.start();
|
|
136
|
+
},
|
|
137
|
+
async stop() {
|
|
138
|
+
if (!running) return;
|
|
139
|
+
running = false;
|
|
140
|
+
await options.scheduler.stop();
|
|
141
|
+
if (pump !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
await pump;
|
|
144
|
+
} catch {
|
|
145
|
+
// Best-effort during stop().
|
|
146
|
+
}
|
|
147
|
+
pump = undefined;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
async status() {
|
|
151
|
+
const all = await options.scheduler.list();
|
|
152
|
+
let active = 0;
|
|
153
|
+
let disabled = 0;
|
|
154
|
+
let deferred = 0;
|
|
155
|
+
for (const t of all) {
|
|
156
|
+
if (t.disabled) disabled += 1;
|
|
157
|
+
else active += 1;
|
|
158
|
+
if (t.missedFires > 0) deferred += 1;
|
|
159
|
+
}
|
|
160
|
+
const out: {
|
|
161
|
+
-readonly [K in keyof TriggersDaemonStatus]?: TriggersDaemonStatus[K];
|
|
162
|
+
} = {
|
|
163
|
+
running,
|
|
164
|
+
active,
|
|
165
|
+
disabled,
|
|
166
|
+
deferred,
|
|
167
|
+
};
|
|
168
|
+
if (lastFireAtMs !== undefined) {
|
|
169
|
+
out.lastFireAt = new Date(lastFireAtMs).toISOString();
|
|
170
|
+
}
|
|
171
|
+
return Object.freeze(out as TriggersDaemonStatus);
|
|
172
|
+
},
|
|
173
|
+
metrics() {
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
fires: new Map(fires),
|
|
176
|
+
catchupApplied,
|
|
177
|
+
libModeWarnings,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the catch-up policy default for triggers that did not
|
|
185
|
+
* declare one explicitly. Returns `'none'` per DEC-150 (personal-
|
|
186
|
+
* assistant friendly).
|
|
187
|
+
*
|
|
188
|
+
* @stable
|
|
189
|
+
*/
|
|
190
|
+
export function defaultCatchupPolicy(): CatchupPolicy {
|
|
191
|
+
return 'none';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeError(err: unknown): string {
|
|
195
|
+
if (err === null || err === undefined) return 'unknown';
|
|
196
|
+
if (err instanceof Error) return err.message;
|
|
197
|
+
return String(err);
|
|
198
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@graphorin/server/triggers` - daemon + REST routes for the
|
|
3
|
+
* triggers scheduler. Phase 14c surface.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
type CreateTriggersDaemonOptions,
|
|
10
|
+
createTriggersDaemon,
|
|
11
|
+
defaultCatchupPolicy,
|
|
12
|
+
type TriggersDaemon,
|
|
13
|
+
type TriggersDaemonMetrics,
|
|
14
|
+
type TriggersDaemonStatus,
|
|
15
|
+
} from './daemon.js';
|
|
16
|
+
export { createTriggersRoutes, type TriggersRoutesDeps } from './routes.js';
|