@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,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle for `createServer({...})` - the `start()` / `stop()` pair
|
|
3
|
+
* behind the {@link GraphorinServer} handle: beforeStart hook, process
|
|
4
|
+
* hardening, pre-bind validation + migrations, audit-chain open,
|
|
5
|
+
* token-verifier construction, route mounting, daemon start order
|
|
6
|
+
* (consolidator first, scheduler last), listener bind, onReady, and
|
|
7
|
+
* the mirrored shutdown sequence (drain, force-abort, dispatcher
|
|
8
|
+
* shutdown, listener close, audit close, store close).
|
|
9
|
+
*
|
|
10
|
+
* The sequencing in this module is part of the documented contract -
|
|
11
|
+
* pure movement only, do not reorder.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AddressInfo } from 'node:net';
|
|
17
|
+
import { applyProcessHardening, generatePepper, TokenVerifier } from '@graphorin/security';
|
|
18
|
+
import { type AuditDb, openAuditDb } from '@graphorin/security/audit';
|
|
19
|
+
import { type GraphorinSqliteStore, readWalSize } from '@graphorin/store-sqlite';
|
|
20
|
+
import { type ServerType, serve } from '@hono/node-server';
|
|
21
|
+
import type { Hono } from 'hono';
|
|
22
|
+
import { ensureStoreAuditBinding } from './app-audit-binding.js';
|
|
23
|
+
import { mountRoutes } from './app-routes.js';
|
|
24
|
+
import type { WsLayer } from './app-ws.js';
|
|
25
|
+
import { bridgeCommentaryToAudit } from './commentary/index.js';
|
|
26
|
+
import type { ServerConfigSpec } from './config.js';
|
|
27
|
+
import type { ConsolidatorDaemon } from './consolidator/daemon.js';
|
|
28
|
+
import {
|
|
29
|
+
LifecycleDoubleStartError,
|
|
30
|
+
LifecycleNotStartedError,
|
|
31
|
+
ShutdownTimeoutError,
|
|
32
|
+
} from './errors/index.js';
|
|
33
|
+
import type { HealthCheckOptions } from './health/index.js';
|
|
34
|
+
import type { ServerVariables } from './internal/context.js';
|
|
35
|
+
import { type LifecycleHooks, type OnErrorContext, runPreBind } from './lifecycle/index.js';
|
|
36
|
+
import { SERVER_METRIC_NAMES } from './metrics/catalog.js';
|
|
37
|
+
import type { MetricRegistry } from './metrics/registry.js';
|
|
38
|
+
import type { AgentRegistry, WorkflowRegistry } from './registry/index.js';
|
|
39
|
+
import type { ReplayApi } from './replay/index.js';
|
|
40
|
+
import type { AuditApi, McpApi, MemoryApi, SessionApi, SkillsApi } from './routes/index.js';
|
|
41
|
+
import { createConsoleRetentionLog, scheduleRetentionSweeps } from './runtime/retention.js';
|
|
42
|
+
import { type RunStateTracker, scheduleRunPruning } from './runtime/run-state.js';
|
|
43
|
+
import { bridgeToolAuditToAudit, type ToolAuditBridge } from './tools-audit-bridge.js';
|
|
44
|
+
import type { TriggersDaemon } from './triggers/daemon.js';
|
|
45
|
+
import type { WorkflowTimerDaemon } from './workflows/timer-daemon.js';
|
|
46
|
+
import type { WsDispatcher, WsTicketStore } from './ws/index.js';
|
|
47
|
+
import { scheduleReplayBufferPruning } from './ws/replay-buffer.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subset of `CreateServerOptions` the lifecycle reads - hooks, test
|
|
51
|
+
* escape hatches, and the domain adapters threaded into the routes.
|
|
52
|
+
*/
|
|
53
|
+
export interface ServerLifecycleOptions {
|
|
54
|
+
readonly sessions?: SessionApi | undefined;
|
|
55
|
+
readonly memory?: MemoryApi | undefined;
|
|
56
|
+
readonly skills?: SkillsApi | undefined;
|
|
57
|
+
readonly mcp?: McpApi | undefined;
|
|
58
|
+
readonly audit?: AuditApi | undefined;
|
|
59
|
+
readonly replay?: ReplayApi | undefined;
|
|
60
|
+
readonly healthProbes?: (() => HealthCheckOptions | Promise<HealthCheckOptions>) | undefined;
|
|
61
|
+
readonly hooks?: LifecycleHooks | undefined;
|
|
62
|
+
readonly probeCipherPeer?: (() => Promise<void>) | undefined;
|
|
63
|
+
readonly skipHardening?: boolean | undefined;
|
|
64
|
+
readonly skipListen?: boolean | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything {@link createLifecycle} needs from the composition root. */
|
|
68
|
+
export interface ServerLifecycleDeps {
|
|
69
|
+
readonly options: ServerLifecycleOptions;
|
|
70
|
+
readonly config: ServerConfigSpec;
|
|
71
|
+
readonly store: GraphorinSqliteStore;
|
|
72
|
+
readonly app: Hono<{ Variables: ServerVariables }>;
|
|
73
|
+
readonly metricRegistry: MetricRegistry;
|
|
74
|
+
readonly runs: RunStateTracker;
|
|
75
|
+
readonly agents: AgentRegistry;
|
|
76
|
+
readonly workflows: WorkflowRegistry;
|
|
77
|
+
readonly version: string;
|
|
78
|
+
readonly startedAt: number;
|
|
79
|
+
readonly now: () => number;
|
|
80
|
+
readonly ws: WsLayer;
|
|
81
|
+
readonly triggersDaemon: TriggersDaemon | undefined;
|
|
82
|
+
readonly consolidatorDaemon: ConsolidatorDaemon | undefined;
|
|
83
|
+
readonly workflowTimerDaemon: WorkflowTimerDaemon | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The stateful `start()` / `stop()` pair plus the live views the
|
|
88
|
+
* {@link GraphorinServer} getters expose (`listeningOn`,
|
|
89
|
+
* `wsDispatcher`, `wsTickets` are cleared during `stop()`).
|
|
90
|
+
*/
|
|
91
|
+
export interface ServerLifecycle {
|
|
92
|
+
readonly listeningOn: { readonly host: string; readonly port: number } | undefined;
|
|
93
|
+
readonly wsDispatcher: WsDispatcher | undefined;
|
|
94
|
+
readonly wsTickets: WsTicketStore | undefined;
|
|
95
|
+
start(): Promise<{ readonly host: string; readonly port: number }>;
|
|
96
|
+
stop(options?: { readonly force?: boolean }): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createLifecycle(deps: ServerLifecycleDeps): ServerLifecycle {
|
|
100
|
+
const {
|
|
101
|
+
options,
|
|
102
|
+
config,
|
|
103
|
+
store,
|
|
104
|
+
app,
|
|
105
|
+
metricRegistry,
|
|
106
|
+
runs,
|
|
107
|
+
agents,
|
|
108
|
+
workflows,
|
|
109
|
+
version,
|
|
110
|
+
startedAt,
|
|
111
|
+
now,
|
|
112
|
+
triggersDaemon,
|
|
113
|
+
consolidatorDaemon,
|
|
114
|
+
workflowTimerDaemon,
|
|
115
|
+
} = deps;
|
|
116
|
+
const commentaryAuditSink = deps.ws.commentaryAuditSink;
|
|
117
|
+
|
|
118
|
+
let serverInstance: ServerType | undefined;
|
|
119
|
+
let listening: { readonly host: string; readonly port: number } | undefined;
|
|
120
|
+
let started = false;
|
|
121
|
+
let stopped = false;
|
|
122
|
+
// IP-16: stops the periodic terminal-run prune sweep on shutdown.
|
|
123
|
+
let stopRunPruning: (() => void) | undefined;
|
|
124
|
+
// W-028: stops the periodic WS replay-buffer TTL sweep on shutdown.
|
|
125
|
+
let stopReplayBufferPruning: (() => void) | undefined;
|
|
126
|
+
// W-010: stops the unified retention sweep (spans, consolidator runs,
|
|
127
|
+
// DLQ, idempotency + the opt-in content surfaces) on shutdown.
|
|
128
|
+
let stopRetention: (() => void) | undefined;
|
|
129
|
+
let auditDb: AuditDb | undefined;
|
|
130
|
+
// W-051: tools/MCP audit-bus -> audit-chain bridge (process-global
|
|
131
|
+
// subscription; must unsubscribe on stop()).
|
|
132
|
+
let toolAuditBridge: ToolAuditBridge | undefined;
|
|
133
|
+
let preBind: Awaited<ReturnType<typeof runPreBind>> | undefined;
|
|
134
|
+
let verifier: TokenVerifier | undefined;
|
|
135
|
+
let pepperHandle: import('@graphorin/security').SecretValue | undefined;
|
|
136
|
+
|
|
137
|
+
let dispatcher = deps.ws.dispatcher;
|
|
138
|
+
let tickets = deps.ws.tickets;
|
|
139
|
+
let wsAdapter = deps.ws.wsAdapter;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
get listeningOn() {
|
|
143
|
+
return listening;
|
|
144
|
+
},
|
|
145
|
+
get wsDispatcher() {
|
|
146
|
+
return dispatcher;
|
|
147
|
+
},
|
|
148
|
+
get wsTickets() {
|
|
149
|
+
return tickets;
|
|
150
|
+
},
|
|
151
|
+
async start(): Promise<{ readonly host: string; readonly port: number }> {
|
|
152
|
+
if (started) throw new LifecycleDoubleStartError();
|
|
153
|
+
started = true;
|
|
154
|
+
try {
|
|
155
|
+
if (options.hooks?.beforeStart !== undefined) {
|
|
156
|
+
await options.hooks.beforeStart({ config });
|
|
157
|
+
}
|
|
158
|
+
if (options.skipHardening !== true && config.hardening.applyOnStart) {
|
|
159
|
+
applyProcessHardening({
|
|
160
|
+
refuseRoot: config.hardening.refuseRoot,
|
|
161
|
+
umask: config.hardening.umask,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
preBind = await runPreBind({
|
|
165
|
+
config,
|
|
166
|
+
store,
|
|
167
|
+
...(options.probeCipherPeer !== undefined
|
|
168
|
+
? { probeCipherPeer: options.probeCipherPeer }
|
|
169
|
+
: {}),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
config.audit.enabled &&
|
|
174
|
+
preBind.auditPassphrase !== undefined &&
|
|
175
|
+
preBind.auditPath !== undefined
|
|
176
|
+
) {
|
|
177
|
+
ensureStoreAuditBinding();
|
|
178
|
+
auditDb = await openAuditDb({
|
|
179
|
+
path: preBind.auditPath,
|
|
180
|
+
passphrase: preBind.auditPassphrase,
|
|
181
|
+
...(config.audit.cipher !== undefined ? { cipher: config.audit.cipher } : {}),
|
|
182
|
+
});
|
|
183
|
+
// IP-21: now that the audit chain is open, route the WS dispatcher's
|
|
184
|
+
// commentary-sanitizer decisions into it.
|
|
185
|
+
commentaryAuditSink?.bind(bridgeCommentaryToAudit(auditDb));
|
|
186
|
+
// W-051: subscribe the audit chain to the tools/MCP audit bus
|
|
187
|
+
// (shadow-mode dataflow findings, approvals, collisions, ...).
|
|
188
|
+
toolAuditBridge = bridgeToolAuditToAudit(auditDb, {
|
|
189
|
+
policy: config.audit.toolEvents,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (config.auth.kind === 'token') {
|
|
194
|
+
const pepper = preBind.pepper ?? (await fallbackPepper(options.skipHardening === true));
|
|
195
|
+
pepperHandle = pepper;
|
|
196
|
+
verifier = new TokenVerifier({
|
|
197
|
+
tokenStore: store.authTokens,
|
|
198
|
+
pepper,
|
|
199
|
+
acceptPrefix: config.auth.tokenPrefix,
|
|
200
|
+
acceptEnvironments: config.auth.tokenEnvironments,
|
|
201
|
+
...(config.auth.perIpFailureThreshold !== undefined
|
|
202
|
+
? { perIpFailureThreshold: config.auth.perIpFailureThreshold }
|
|
203
|
+
: {}),
|
|
204
|
+
...(config.auth.perIpLockoutMs !== undefined
|
|
205
|
+
? { perIpLockoutMs: config.auth.perIpLockoutMs }
|
|
206
|
+
: {}),
|
|
207
|
+
now,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
mountRoutes(app, config, {
|
|
212
|
+
version,
|
|
213
|
+
startedAt,
|
|
214
|
+
now,
|
|
215
|
+
agents,
|
|
216
|
+
workflows,
|
|
217
|
+
runs,
|
|
218
|
+
store,
|
|
219
|
+
metricRegistry,
|
|
220
|
+
...(options.sessions !== undefined ? { sessions: options.sessions } : {}),
|
|
221
|
+
...(options.memory !== undefined ? { memory: options.memory } : {}),
|
|
222
|
+
...(options.skills !== undefined ? { skills: options.skills } : {}),
|
|
223
|
+
...(options.mcp !== undefined ? { mcp: options.mcp } : {}),
|
|
224
|
+
...(options.audit !== undefined ? { audit: options.audit } : {}),
|
|
225
|
+
...(options.replay !== undefined ? { replay: options.replay } : {}),
|
|
226
|
+
...(options.healthProbes !== undefined ? { healthProbes: options.healthProbes } : {}),
|
|
227
|
+
...(verifier !== undefined ? { verifier } : {}),
|
|
228
|
+
...(auditDb !== undefined ? { auditDb } : {}),
|
|
229
|
+
...(pepperHandle !== undefined ? { pepper: pepperHandle } : {}),
|
|
230
|
+
...(dispatcher !== undefined ? { wsDispatcher: dispatcher } : {}),
|
|
231
|
+
...(tickets !== undefined ? { wsTickets: tickets } : {}),
|
|
232
|
+
...(wsAdapter !== undefined ? { wsAdapter } : {}),
|
|
233
|
+
...(triggersDaemon !== undefined ? { triggersDaemon } : {}),
|
|
234
|
+
...(consolidatorDaemon !== undefined ? { consolidatorDaemon } : {}),
|
|
235
|
+
...(workflowTimerDaemon !== undefined ? { workflowTimerDaemon } : {}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Start the consolidator first so it is ready to handle fired
|
|
239
|
+
// triggers, bridge its cron / idle triggers onto the scheduler
|
|
240
|
+
// (MCON-4 - without this nothing pipes triggers into the
|
|
241
|
+
// consolidator and background consolidation never runs), then start
|
|
242
|
+
// the scheduler last so it only fires fully-wired triggers.
|
|
243
|
+
if (consolidatorDaemon !== undefined) {
|
|
244
|
+
await consolidatorDaemon.start();
|
|
245
|
+
if (
|
|
246
|
+
triggersDaemon !== undefined &&
|
|
247
|
+
consolidatorDaemon.consolidator.registerWithScheduler !== undefined
|
|
248
|
+
) {
|
|
249
|
+
await consolidatorDaemon.consolidator.registerWithScheduler(triggersDaemon.scheduler);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (triggersDaemon !== undefined) {
|
|
253
|
+
await triggersDaemon.start();
|
|
254
|
+
}
|
|
255
|
+
// W-032: fire due durable timers from server start onward.
|
|
256
|
+
if (workflowTimerDaemon !== undefined) {
|
|
257
|
+
await workflowTimerDaemon.start();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Sample a couple of gauges immediately so the very first
|
|
261
|
+
// `/v1/metrics` scrape after start carries non-zero data.
|
|
262
|
+
try {
|
|
263
|
+
const wal = readWalSize(store.connection);
|
|
264
|
+
metricRegistry.set(SERVER_METRIC_NAMES.storageWalSize, wal);
|
|
265
|
+
} catch {
|
|
266
|
+
// Best-effort.
|
|
267
|
+
}
|
|
268
|
+
metricRegistry.set(
|
|
269
|
+
SERVER_METRIC_NAMES.serverUptime,
|
|
270
|
+
Math.max(0, Math.floor((now() - startedAt) / 1000)),
|
|
271
|
+
);
|
|
272
|
+
metricRegistry.set(SERVER_METRIC_NAMES.inflightRuns, runs.runningCount());
|
|
273
|
+
metricRegistry.set(SERVER_METRIC_NAMES.replayBufferEvents, 0);
|
|
274
|
+
|
|
275
|
+
// IP-16: terminal run records (each holding an AbortController) would
|
|
276
|
+
// otherwise accumulate forever; sweep them on a periodic timer.
|
|
277
|
+
stopRunPruning = scheduleRunPruning(runs, now);
|
|
278
|
+
|
|
279
|
+
// W-028: finished-run replay subjects (fresh runId per run, no
|
|
280
|
+
// further push/replay activity) would otherwise hold up to
|
|
281
|
+
// maxEvents payloads forever; sweep the buffer's TTL
|
|
282
|
+
// periodically. Guarded: the dispatcher is absent on no-WS
|
|
283
|
+
// configurations.
|
|
284
|
+
if (dispatcher !== undefined) {
|
|
285
|
+
stopReplayBufferPruning = scheduleReplayBufferPruning(dispatcher.replayBuffer, {
|
|
286
|
+
intervalMs: config.server.stream.replayBuffer.pruneIntervalSeconds * 1000,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// W-010 / W-008: the unified retention sweep over every SQLite
|
|
291
|
+
// growth surface, including the W-061 idempotency sweep it
|
|
292
|
+
// subsumes. Runs after runPreBind, so migrations have applied;
|
|
293
|
+
// the first sweep fires immediately.
|
|
294
|
+
const retentionLog = createConsoleRetentionLog(config.observability.logger);
|
|
295
|
+
stopRetention = scheduleRetentionSweeps({
|
|
296
|
+
store,
|
|
297
|
+
config: config.retention,
|
|
298
|
+
now,
|
|
299
|
+
...(retentionLog !== undefined ? { log: retentionLog } : {}),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (options.skipListen !== true) {
|
|
303
|
+
serverInstance = serve({
|
|
304
|
+
fetch: app.fetch.bind(app),
|
|
305
|
+
hostname: config.server.host,
|
|
306
|
+
port: config.server.port,
|
|
307
|
+
});
|
|
308
|
+
if (wsAdapter !== undefined && serverInstance !== undefined) {
|
|
309
|
+
wsAdapter.injectWebSocket(serverInstance);
|
|
310
|
+
}
|
|
311
|
+
await new Promise<void>((resolve) => {
|
|
312
|
+
const server = serverInstance as unknown as {
|
|
313
|
+
once(event: 'listening', cb: () => void): void;
|
|
314
|
+
};
|
|
315
|
+
if ('once' in server && typeof server.once === 'function') {
|
|
316
|
+
server.once('listening', () => resolve());
|
|
317
|
+
} else {
|
|
318
|
+
// serve(...) returned a Node http.Server already bound.
|
|
319
|
+
setImmediate(() => resolve());
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const address = (
|
|
323
|
+
serverInstance as unknown as { address(): AddressInfo | string | null }
|
|
324
|
+
).address();
|
|
325
|
+
if (address !== null && typeof address === 'object') {
|
|
326
|
+
listening = { host: address.address, port: address.port };
|
|
327
|
+
} else {
|
|
328
|
+
listening = { host: config.server.host, port: config.server.port };
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
listening = { host: config.server.host, port: config.server.port };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (options.hooks?.onReady !== undefined) {
|
|
335
|
+
await options.hooks.onReady({ config, listeningOn: listening });
|
|
336
|
+
}
|
|
337
|
+
return listening;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
started = false;
|
|
340
|
+
await emitError(options.hooks, { error: err, phase: 'beforeStart' });
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
async stop({ force }: { readonly force?: boolean } = {}): Promise<void> {
|
|
345
|
+
if (!started) throw new LifecycleNotStartedError();
|
|
346
|
+
if (stopped) return;
|
|
347
|
+
stopped = true;
|
|
348
|
+
// IP-16: halt the prune sweep before draining.
|
|
349
|
+
stopRunPruning?.();
|
|
350
|
+
stopRunPruning = undefined;
|
|
351
|
+
// W-028: halt the replay-buffer TTL sweep symmetrically.
|
|
352
|
+
stopReplayBufferPruning?.();
|
|
353
|
+
stopReplayBufferPruning = undefined;
|
|
354
|
+
// W-010: halt the retention sweep symmetrically.
|
|
355
|
+
stopRetention?.();
|
|
356
|
+
stopRetention = undefined;
|
|
357
|
+
// W-051: unsubscribe from the tools audit bus and let in-flight
|
|
358
|
+
// audit writes settle before the audit DB closes below.
|
|
359
|
+
if (toolAuditBridge !== undefined) {
|
|
360
|
+
toolAuditBridge.stop();
|
|
361
|
+
try {
|
|
362
|
+
await toolAuditBridge.drain();
|
|
363
|
+
} catch {
|
|
364
|
+
// Write errors were already surfaced by the bridge itself.
|
|
365
|
+
}
|
|
366
|
+
toolAuditBridge = undefined;
|
|
367
|
+
}
|
|
368
|
+
const drainTimeoutMs = force === true ? 0 : config.server.shutdown.drainTimeoutMs;
|
|
369
|
+
try {
|
|
370
|
+
if (options.hooks?.beforeShutdown !== undefined) {
|
|
371
|
+
await options.hooks.beforeShutdown({
|
|
372
|
+
config,
|
|
373
|
+
inflight: runs.inflightCount(),
|
|
374
|
+
drainTimeoutMs,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
await emitError(options.hooks, { error: err, phase: 'beforeShutdown' });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (workflowTimerDaemon !== undefined) {
|
|
382
|
+
try {
|
|
383
|
+
await workflowTimerDaemon.stop();
|
|
384
|
+
} catch (err) {
|
|
385
|
+
await emitError(options.hooks, { error: err, phase: 'beforeShutdown' });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (triggersDaemon !== undefined) {
|
|
389
|
+
try {
|
|
390
|
+
await triggersDaemon.stop();
|
|
391
|
+
} catch (err) {
|
|
392
|
+
await emitError(options.hooks, { error: err, phase: 'beforeShutdown' });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (consolidatorDaemon !== undefined) {
|
|
396
|
+
try {
|
|
397
|
+
await consolidatorDaemon.stop();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
await emitError(options.hooks, { error: err, phase: 'beforeShutdown' });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Pending reservations (e.g. awaited WS subscriptions for the
|
|
404
|
+
// streaming endpoints) hold no work in progress; abort them
|
|
405
|
+
// immediately so the drain only waits for live runs.
|
|
406
|
+
runs.abortPending('server-shutdown');
|
|
407
|
+
const drained = await drainInFlight(runs, drainTimeoutMs, now);
|
|
408
|
+
if (!drained && drainTimeoutMs > 0) {
|
|
409
|
+
// Force-abort everything that didn't drain in time.
|
|
410
|
+
runs.abortAll(new ShutdownTimeoutError(drainTimeoutMs, runs.runningCount()));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (dispatcher !== undefined) {
|
|
414
|
+
try {
|
|
415
|
+
dispatcher.shutdown();
|
|
416
|
+
} catch {
|
|
417
|
+
// Best-effort during stop().
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (serverInstance !== undefined) {
|
|
422
|
+
await new Promise<void>((resolve) => {
|
|
423
|
+
(serverInstance as unknown as { close(cb: () => void): void }).close(() => resolve());
|
|
424
|
+
});
|
|
425
|
+
serverInstance = undefined;
|
|
426
|
+
}
|
|
427
|
+
if (auditDb !== undefined) {
|
|
428
|
+
await auditDb.close();
|
|
429
|
+
auditDb = undefined;
|
|
430
|
+
}
|
|
431
|
+
await store.close();
|
|
432
|
+
listening = undefined;
|
|
433
|
+
verifier = undefined;
|
|
434
|
+
started = false;
|
|
435
|
+
preBind = undefined;
|
|
436
|
+
dispatcher = undefined;
|
|
437
|
+
tickets = undefined;
|
|
438
|
+
wsAdapter = undefined;
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function drainInFlight(
|
|
444
|
+
runs: RunStateTracker,
|
|
445
|
+
drainTimeoutMs: number,
|
|
446
|
+
now: () => number,
|
|
447
|
+
): Promise<boolean> {
|
|
448
|
+
if (runs.runningCount() === 0) return true;
|
|
449
|
+
if (drainTimeoutMs <= 0) return runs.runningCount() === 0;
|
|
450
|
+
const deadline = now() + drainTimeoutMs;
|
|
451
|
+
while (now() < deadline) {
|
|
452
|
+
if (runs.runningCount() === 0) return true;
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, deadline - now())));
|
|
454
|
+
}
|
|
455
|
+
return runs.runningCount() === 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function emitError(hooks: LifecycleHooks | undefined, ctx: OnErrorContext): Promise<void> {
|
|
459
|
+
if (hooks?.onError === undefined) return;
|
|
460
|
+
try {
|
|
461
|
+
await hooks.onError(ctx);
|
|
462
|
+
} catch {
|
|
463
|
+
// onError must never throw further; swallow.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function fallbackPepper(skipHardening: boolean) {
|
|
468
|
+
// Test-only path: when `skipHardening` is set we mint an ephemeral
|
|
469
|
+
// pepper so the verifier is constructible without a real keyring.
|
|
470
|
+
if (!skipHardening) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
'[graphorin/server] missing resolved pepper after pre-bind; auth.pepperRef is required when auth.kind = token.',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return generatePepper();
|
|
476
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-metrics refresh for the `/v1/metrics` exposition - samples the
|
|
3
|
+
* WAL size, uptime, in-flight runs, replay-buffer occupancy, trigger
|
|
4
|
+
* fire counters, and consolidator gauges on every scrape. Wired into
|
|
5
|
+
* the metrics route by `app-routes.ts`.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type GraphorinSqliteStore, readWalSize } from '@graphorin/store-sqlite';
|
|
11
|
+
import type { ConsolidatorDaemon } from './consolidator/daemon.js';
|
|
12
|
+
import { SERVER_METRIC_NAMES } from './metrics/catalog.js';
|
|
13
|
+
import type { MetricRegistry } from './metrics/registry.js';
|
|
14
|
+
import { syncToolCounters } from './metrics/tools-bridge.js';
|
|
15
|
+
import type { RunStateTracker } from './runtime/run-state.js';
|
|
16
|
+
import type { TriggersDaemon } from './triggers/daemon.js';
|
|
17
|
+
import type { WsDispatcher } from './ws/index.js';
|
|
18
|
+
|
|
19
|
+
export interface RefreshLiveMetricsOptions {
|
|
20
|
+
readonly registry: MetricRegistry;
|
|
21
|
+
readonly store: GraphorinSqliteStore;
|
|
22
|
+
readonly runs: RunStateTracker;
|
|
23
|
+
readonly startedAt: number;
|
|
24
|
+
readonly now: () => number;
|
|
25
|
+
readonly triggersDaemon?: TriggersDaemon;
|
|
26
|
+
readonly consolidatorDaemon?: ConsolidatorDaemon;
|
|
27
|
+
readonly wsDispatcher?: WsDispatcher;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function refreshLiveMetrics(options: RefreshLiveMetricsOptions): Promise<void> {
|
|
31
|
+
const { registry, store, runs, startedAt, now } = options;
|
|
32
|
+
|
|
33
|
+
// W-051: fold the tools/MCP module counters (dataflow shadow signals,
|
|
34
|
+
// executor retries, reconnects, ...) into the scrape.
|
|
35
|
+
syncToolCounters(registry);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const wal = readWalSize(store.connection);
|
|
39
|
+
registry.set(SERVER_METRIC_NAMES.storageWalSize, wal);
|
|
40
|
+
} catch {
|
|
41
|
+
// Best-effort.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
registry.set(
|
|
45
|
+
SERVER_METRIC_NAMES.serverUptime,
|
|
46
|
+
Math.max(0, Math.floor((now() - startedAt) / 1000)),
|
|
47
|
+
);
|
|
48
|
+
registry.set(SERVER_METRIC_NAMES.inflightRuns, runs.runningCount());
|
|
49
|
+
|
|
50
|
+
if (options.wsDispatcher !== undefined) {
|
|
51
|
+
// W-028: the gauge now reports buffered EVENTS, matching its name -
|
|
52
|
+
// previously it was filled with the subscription count. `stats` is
|
|
53
|
+
// optional on the @stable ReplayBuffer interface, so external
|
|
54
|
+
// implementations without it degrade to 0 instead of throwing.
|
|
55
|
+
registry.set(
|
|
56
|
+
SERVER_METRIC_NAMES.replayBufferEvents,
|
|
57
|
+
options.wsDispatcher.replayBuffer.stats?.().events ?? 0,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (options.triggersDaemon !== undefined) {
|
|
62
|
+
const metrics = options.triggersDaemon.metrics();
|
|
63
|
+
for (const [triggerId, counts] of metrics.fires) {
|
|
64
|
+
const sanitized = sanitizeMetricLabelValue(triggerId);
|
|
65
|
+
const successCurrent = readCounter(registry, SERVER_METRIC_NAMES.triggersFiresTotal, {
|
|
66
|
+
trigger_id: sanitized,
|
|
67
|
+
status: 'success',
|
|
68
|
+
});
|
|
69
|
+
const errorCurrent = readCounter(registry, SERVER_METRIC_NAMES.triggersFiresTotal, {
|
|
70
|
+
trigger_id: sanitized,
|
|
71
|
+
status: 'error',
|
|
72
|
+
});
|
|
73
|
+
const successDelta = counts.success - successCurrent;
|
|
74
|
+
const errorDelta = counts.error - errorCurrent;
|
|
75
|
+
if (successDelta > 0) {
|
|
76
|
+
registry.inc(
|
|
77
|
+
SERVER_METRIC_NAMES.triggersFiresTotal,
|
|
78
|
+
{ trigger_id: sanitized, status: 'success' },
|
|
79
|
+
successDelta,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (errorDelta > 0) {
|
|
83
|
+
registry.inc(
|
|
84
|
+
SERVER_METRIC_NAMES.triggersFiresTotal,
|
|
85
|
+
{ trigger_id: sanitized, status: 'error' },
|
|
86
|
+
errorDelta,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.consolidatorDaemon !== undefined) {
|
|
93
|
+
try {
|
|
94
|
+
const status = await options.consolidatorDaemon.status();
|
|
95
|
+
registry.set(SERVER_METRIC_NAMES.consolidatorQueueDepth, status.queueDepth, {
|
|
96
|
+
phase: 'aggregate',
|
|
97
|
+
});
|
|
98
|
+
registry.set(SERVER_METRIC_NAMES.consolidatorDlqSize, status.dlqSize);
|
|
99
|
+
registry.set(SERVER_METRIC_NAMES.consolidatorBudgetRemainingUsd, status.budget.costRemaining);
|
|
100
|
+
registry.set(
|
|
101
|
+
SERVER_METRIC_NAMES.consolidatorBudgetRemainingTokens,
|
|
102
|
+
status.budget.tokensRemaining,
|
|
103
|
+
);
|
|
104
|
+
} catch {
|
|
105
|
+
// Best-effort.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert an arbitrary user-supplied identifier (trigger id) into a
|
|
112
|
+
* Prometheus-safe label value. Replaces every character outside the
|
|
113
|
+
* `[A-Za-z0-9_:]` range with `_`. This guarantees the cardinality
|
|
114
|
+
* never explodes on UTF-8 sequences while keeping the value
|
|
115
|
+
* recognizable.
|
|
116
|
+
*/
|
|
117
|
+
function sanitizeMetricLabelValue(value: string): string {
|
|
118
|
+
return value.replace(/[^A-Za-z0-9_:.-]/g, '_').slice(0, 200);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readCounter(
|
|
122
|
+
registry: MetricRegistry,
|
|
123
|
+
name: string,
|
|
124
|
+
labels: Record<string, string>,
|
|
125
|
+
): number {
|
|
126
|
+
const snap = registry.snapshot().counters[name] ?? [];
|
|
127
|
+
for (const entry of snap) {
|
|
128
|
+
if (matchesLabels(entry.labels, labels)) return entry.value;
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function matchesLabels(
|
|
134
|
+
a: Record<string, string | number | boolean>,
|
|
135
|
+
b: Record<string, string>,
|
|
136
|
+
): boolean {
|
|
137
|
+
for (const k of Object.keys(b)) {
|
|
138
|
+
if (String(a[k]) !== b[k]) return false;
|
|
139
|
+
}
|
|
140
|
+
for (const k of Object.keys(a)) {
|
|
141
|
+
if (!(k in b)) return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|