@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.
Files changed (181) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +35 -607
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +8 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +19 -24
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +1 -1
  59. package/dist/package.js.map +1 -1
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.js +19 -12
  99. package/dist/ws/dispatcher.js.map +1 -1
  100. package/dist/ws/index.d.ts +2 -2
  101. package/dist/ws/index.js +3 -3
  102. package/dist/ws/replay-buffer.d.ts +35 -1
  103. package/dist/ws/replay-buffer.d.ts.map +1 -1
  104. package/dist/ws/replay-buffer.js +35 -3
  105. package/dist/ws/replay-buffer.js.map +1 -1
  106. package/dist/ws/upgrade.d.ts.map +1 -1
  107. package/dist/ws/upgrade.js +24 -18
  108. package/dist/ws/upgrade.js.map +1 -1
  109. package/package.json +30 -29
  110. package/src/app-audit-binding.ts +227 -0
  111. package/src/app-daemons.ts +73 -0
  112. package/src/app-lifecycle.ts +476 -0
  113. package/src/app-metrics.ts +144 -0
  114. package/src/app-middleware.ts +149 -0
  115. package/src/app-routes.ts +305 -0
  116. package/src/app-ws.ts +111 -0
  117. package/src/app.ts +317 -0
  118. package/src/commentary/audit-bridge.ts +135 -0
  119. package/src/commentary/built-in-patterns.ts +79 -0
  120. package/src/commentary/index.ts +32 -0
  121. package/src/commentary/sanitizer.ts +238 -0
  122. package/src/commentary/types.ts +130 -0
  123. package/src/config.ts +472 -0
  124. package/src/consolidator/daemon.ts +141 -0
  125. package/src/consolidator/index.ts +14 -0
  126. package/src/errors/index.ts +247 -0
  127. package/src/health/checks.ts +322 -0
  128. package/src/health/index.ts +34 -0
  129. package/src/health/routes.ts +154 -0
  130. package/src/index.ts +243 -0
  131. package/src/internal/context.ts +77 -0
  132. package/src/internal/ids.ts +17 -0
  133. package/src/internal/json.ts +47 -0
  134. package/src/internal/wire-error.ts +44 -0
  135. package/src/lifecycle/hooks.ts +66 -0
  136. package/src/lifecycle/index.ts +16 -0
  137. package/src/lifecycle/pre-bind.ts +138 -0
  138. package/src/metrics/catalog.ts +112 -0
  139. package/src/metrics/index.ts +12 -0
  140. package/src/metrics/registry.ts +337 -0
  141. package/src/metrics/tools-bridge.ts +124 -0
  142. package/src/middleware/audit.ts +114 -0
  143. package/src/middleware/auth.ts +195 -0
  144. package/src/middleware/cors.ts +72 -0
  145. package/src/middleware/csrf.ts +98 -0
  146. package/src/middleware/idempotency.ts +336 -0
  147. package/src/middleware/index.ts +38 -0
  148. package/src/middleware/rate-limit.ts +71 -0
  149. package/src/middleware/request-state.ts +79 -0
  150. package/src/middleware/scope.ts +161 -0
  151. package/src/registry/index.ts +278 -0
  152. package/src/replay/index.ts +14 -0
  153. package/src/replay/routes.ts +255 -0
  154. package/src/routes/agents.ts +363 -0
  155. package/src/routes/audit.ts +207 -0
  156. package/src/routes/auth.ts +80 -0
  157. package/src/routes/health.ts +42 -0
  158. package/src/routes/index.ts +16 -0
  159. package/src/routes/mcp.ts +97 -0
  160. package/src/routes/memory.ts +152 -0
  161. package/src/routes/sessions.ts +250 -0
  162. package/src/routes/skills.ts +91 -0
  163. package/src/routes/tokens.ts +166 -0
  164. package/src/routes/workflows.ts +616 -0
  165. package/src/runtime/retention.ts +284 -0
  166. package/src/runtime/run-state.ts +422 -0
  167. package/src/sse/events.ts +303 -0
  168. package/src/sse/index.ts +7 -0
  169. package/src/tools-audit-bridge.ts +120 -0
  170. package/src/triggers/daemon.ts +198 -0
  171. package/src/triggers/index.ts +16 -0
  172. package/src/triggers/routes.ts +139 -0
  173. package/src/workflows/timer-daemon.ts +102 -0
  174. package/src/ws/dispatcher.ts +537 -0
  175. package/src/ws/index.ts +45 -0
  176. package/src/ws/replay-buffer.ts +209 -0
  177. package/src/ws/subjects.ts +162 -0
  178. package/src/ws/ticket.ts +174 -0
  179. package/src/ws/upgrade.ts +507 -0
  180. package/dist/lifecycle/index.js +0 -3
  181. 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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Server-Sent Events fallback transport for `@graphorin/server`.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { createSseRoutes, type SseRoutesDeps } from './events.js';
@@ -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';