@agentsquared/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/a2_cli.mjs +1576 -0
- package/adapters/index.mjs +79 -0
- package/adapters/openclaw/adapter.mjs +1020 -0
- package/adapters/openclaw/cli.mjs +89 -0
- package/adapters/openclaw/detect.mjs +259 -0
- package/adapters/openclaw/helpers.mjs +827 -0
- package/adapters/openclaw/ws_client.mjs +740 -0
- package/bin/a2-cli.js +8 -0
- package/lib/conversation/policy.mjs +122 -0
- package/lib/conversation/store.mjs +223 -0
- package/lib/conversation/templates.mjs +419 -0
- package/lib/gateway/api.mjs +28 -0
- package/lib/gateway/inbox.mjs +344 -0
- package/lib/gateway/lifecycle.mjs +602 -0
- package/lib/gateway/runtime_state.mjs +388 -0
- package/lib/gateway/server.mjs +883 -0
- package/lib/gateway/state.mjs +175 -0
- package/lib/routing/agent_router.mjs +511 -0
- package/lib/runtime/executor.mjs +380 -0
- package/lib/runtime/keys.mjs +85 -0
- package/lib/runtime/report.mjs +302 -0
- package/lib/runtime/safety.mjs +72 -0
- package/lib/shared/paths.mjs +155 -0
- package/lib/shared/primitives.mjs +43 -0
- package/lib/transport/http_json.mjs +96 -0
- package/lib/transport/libp2p.mjs +397 -0
- package/lib/transport/peer_session.mjs +857 -0
- package/lib/transport/relay_http.mjs +110 -0
- package/package.json +53 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { URL } from 'node:url'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
import { parseArgs, parseList, requireArg } from '../shared/primitives.mjs'
|
|
9
|
+
import { getBindingDocument, getFriendDirectory } from '../transport/relay_http.mjs'
|
|
10
|
+
import { loadRuntimeKeyBundle } from '../runtime/keys.mjs'
|
|
11
|
+
import { DEFAULT_LISTEN_ADDRS, buildRelayListenAddrs, createNode, directListenAddrs, relayReservationAddrs } from '../transport/libp2p.mjs'
|
|
12
|
+
import { attachInboundRouter, currentTransport, openDirectPeerSession, publishGatewayPresence } from '../transport/peer_session.mjs'
|
|
13
|
+
import { assertGatewayStateFresh, currentRuntimeRevision, defaultGatewayStateFile, readGatewayState, writeGatewayState } from './state.mjs'
|
|
14
|
+
import { createGatewayRuntimeState } from './runtime_state.mjs'
|
|
15
|
+
import { createAgentRouter, DEFAULT_ROUTER_DEFAULT_SKILL, DEFAULT_ROUTER_SKILLS } from '../routing/agent_router.mjs'
|
|
16
|
+
import { createLocalRuntimeExecutor, createOwnerNotifier } from '../runtime/executor.mjs'
|
|
17
|
+
import { createInboxStore } from './inbox.mjs'
|
|
18
|
+
import { createLiveConversationStore } from '../conversation/store.mjs'
|
|
19
|
+
import { defaultInboxDir, defaultOpenClawStateDir, defaultPeerKeyFile as defaultPeerKeyFileFromLayout } from '../shared/paths.mjs'
|
|
20
|
+
import { detectHostRuntimeEnvironment } from '../../adapters/index.mjs'
|
|
21
|
+
import { resolveOpenClawAgentSelection } from '../../adapters/openclaw/detect.mjs'
|
|
22
|
+
import { buildStandardRuntimeOwnerLines, buildStandardRuntimeReport, currentRuntimeMetadata } from '../runtime/report.mjs'
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
25
|
+
|
|
26
|
+
const DEFAULT_GATEWAY_HOST = '127.0.0.1'
|
|
27
|
+
const DEFAULT_GATEWAY_PORT = 0
|
|
28
|
+
const DEFAULT_PRESENCE_REFRESH_MS = 30 * 60 * 1000
|
|
29
|
+
const DEFAULT_HEALTH_CHECK_MS = 15 * 1000
|
|
30
|
+
const DEFAULT_RELAY_CONTROL_CHECK_MS = 5 * 60 * 1000
|
|
31
|
+
const DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS = 1500
|
|
32
|
+
const DEFAULT_RECOVERY_IDLE_WAIT_MS = 3000
|
|
33
|
+
const DEFAULT_FAILURES_BEFORE_RECOVER = 2
|
|
34
|
+
const DEFAULT_ROUTER_WAIT_MS = 30000
|
|
35
|
+
|
|
36
|
+
function clean(value) {
|
|
37
|
+
return `${value ?? ''}`.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nowISO() {
|
|
41
|
+
return new Date().toISOString()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sleep(ms) {
|
|
45
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toOwnerFacingText(lines = []) {
|
|
49
|
+
return lines.filter(Boolean).join('\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function jsonResponse(res, status, payload) {
|
|
53
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
54
|
+
res.end(JSON.stringify(payload))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readJson(req) {
|
|
58
|
+
const chunks = []
|
|
59
|
+
for await (const chunk of req) {
|
|
60
|
+
chunks.push(Buffer.from(chunk))
|
|
61
|
+
}
|
|
62
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
63
|
+
return raw ? JSON.parse(raw) : {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultPeerKeyFile(keyFile, agentId) {
|
|
67
|
+
return defaultPeerKeyFileFromLayout(keyFile, agentId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runGateway(argv) {
|
|
71
|
+
const args = parseArgs(argv)
|
|
72
|
+
const apiBase = (args['api-base'] ?? 'https://api.agentsquared.net').trim()
|
|
73
|
+
const agentId = requireArg(args['agent-id'], '--agent-id is required')
|
|
74
|
+
const keyFile = requireArg(args['key-file'], '--key-file is required')
|
|
75
|
+
const gatewayHost = (args['gateway-host'] ?? DEFAULT_GATEWAY_HOST).trim()
|
|
76
|
+
const gatewayPort = Number.parseInt(args['gateway-port'] ?? `${DEFAULT_GATEWAY_PORT}`, 10)
|
|
77
|
+
const presenceRefreshMs = Math.max(0, Number.parseInt(args['presence-refresh-ms'] ?? `${DEFAULT_PRESENCE_REFRESH_MS}`, 10) || DEFAULT_PRESENCE_REFRESH_MS)
|
|
78
|
+
const healthCheckMs = Math.max(1000, Number.parseInt(args['health-check-ms'] ?? `${DEFAULT_HEALTH_CHECK_MS}`, 10) || DEFAULT_HEALTH_CHECK_MS)
|
|
79
|
+
const relayControlCheckMs = Math.max(1000, Number.parseInt(args['relay-control-check-ms'] ?? `${DEFAULT_RELAY_CONTROL_CHECK_MS}`, 10) || DEFAULT_RELAY_CONTROL_CHECK_MS)
|
|
80
|
+
const transportCheckTimeoutMs = Math.max(250, Number.parseInt(args['transport-check-timeout-ms'] ?? `${DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS}`, 10) || DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS)
|
|
81
|
+
const recoveryIdleWaitMs = Math.max(0, Number.parseInt(args['recovery-idle-wait-ms'] ?? `${DEFAULT_RECOVERY_IDLE_WAIT_MS}`, 10) || DEFAULT_RECOVERY_IDLE_WAIT_MS)
|
|
82
|
+
const failuresBeforeRecover = Math.max(1, Number.parseInt(args['failures-before-recover'] ?? `${DEFAULT_FAILURES_BEFORE_RECOVER}`, 10) || DEFAULT_FAILURES_BEFORE_RECOVER)
|
|
83
|
+
const routerMode = `${args['router-mode'] ?? 'integrated'}`.trim().toLowerCase() === 'external' ? 'external' : 'integrated'
|
|
84
|
+
const routerWaitMs = Math.max(0, Number.parseInt(args['wait-ms'] ?? `${DEFAULT_ROUTER_WAIT_MS}`, 10) || DEFAULT_ROUTER_WAIT_MS)
|
|
85
|
+
const maxActiveMailboxes = Math.max(1, Number.parseInt(args['max-active-mailboxes'] ?? '8', 10) || 8)
|
|
86
|
+
const routerSkills = parseList(args['router-skills'] ?? args['allowed-skills'], DEFAULT_ROUTER_SKILLS)
|
|
87
|
+
const defaultSkill = (args['default-skill'] ?? args['fallback-skill'] ?? DEFAULT_ROUTER_DEFAULT_SKILL).trim() || DEFAULT_ROUTER_DEFAULT_SKILL
|
|
88
|
+
const hostRuntime = `${args['host-runtime'] ?? 'auto'}`.trim().toLowerCase() || 'auto'
|
|
89
|
+
const openclawAgent = `${args['openclaw-agent'] ?? process.env.OPENCLAW_AGENT ?? ''}`.trim()
|
|
90
|
+
const openclawCommand = `${args['openclaw-command'] ?? process.env.OPENCLAW_COMMAND ?? 'openclaw'}`.trim() || 'openclaw'
|
|
91
|
+
const openclawCwd = `${args['openclaw-cwd'] ?? process.env.OPENCLAW_CWD ?? ''}`.trim()
|
|
92
|
+
const openclawConfigPath = `${args['openclaw-config-path'] ?? process.env.OPENCLAW_CONFIG_PATH ?? ''}`.trim()
|
|
93
|
+
const openclawSessionPrefix = `${args['openclaw-session-prefix'] ?? args['openclaw-peer-target-prefix'] ?? process.env.OPENCLAW_SESSION_PREFIX ?? process.env.OPENCLAW_PEER_TARGET_PREFIX ?? 'agentsquared:'}`.trim() || 'agentsquared:'
|
|
94
|
+
const openclawTimeoutMs = Math.max(1000, Number.parseInt(args['openclaw-timeout-ms'] ?? `${process.env.OPENCLAW_TIMEOUT_MS ?? '180000'}`, 10) || 180000)
|
|
95
|
+
const openclawGatewayUrl = `${args['openclaw-gateway-url'] ?? process.env.OPENCLAW_GATEWAY_URL ?? ''}`.trim()
|
|
96
|
+
const openclawGatewayToken = `${args['openclaw-gateway-token'] ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ''}`.trim()
|
|
97
|
+
const openclawGatewayPassword = `${args['openclaw-gateway-password'] ?? process.env.OPENCLAW_GATEWAY_PASSWORD ?? ''}`.trim()
|
|
98
|
+
const peerKeyFile = (args['peer-key-file'] ?? defaultPeerKeyFile(keyFile, agentId)).trim()
|
|
99
|
+
const gatewayStateFile = (args['gateway-state-file'] ?? defaultGatewayStateFile(keyFile, agentId)).trim()
|
|
100
|
+
const inboxDir = (args['inbox-dir'] ?? defaultInboxDir(keyFile, agentId)).trim()
|
|
101
|
+
const listenAddrs = parseList(args['listen-addrs'], DEFAULT_LISTEN_ADDRS)
|
|
102
|
+
const bundle = loadRuntimeKeyBundle(keyFile)
|
|
103
|
+
const runtimeState = createGatewayRuntimeState()
|
|
104
|
+
const conversationStore = createLiveConversationStore()
|
|
105
|
+
const inboxStore = createInboxStore({ inboxDir })
|
|
106
|
+
const runtimeRevision = currentRuntimeRevision()
|
|
107
|
+
const currentRuntime = currentRuntimeMetadata()
|
|
108
|
+
const previousGatewayState = readGatewayState(gatewayStateFile)
|
|
109
|
+
const detectedHostRuntime = await detectHostRuntimeEnvironment({
|
|
110
|
+
preferred: hostRuntime,
|
|
111
|
+
openclaw: {
|
|
112
|
+
command: openclawCommand,
|
|
113
|
+
cwd: openclawCwd,
|
|
114
|
+
openclawAgent,
|
|
115
|
+
configPath: openclawConfigPath,
|
|
116
|
+
gatewayUrl: openclawGatewayUrl,
|
|
117
|
+
gatewayToken: openclawGatewayToken,
|
|
118
|
+
gatewayPassword: openclawGatewayPassword
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
const resolvedHostRuntime = detectedHostRuntime.resolved || 'none'
|
|
122
|
+
if (resolvedHostRuntime !== 'openclaw') {
|
|
123
|
+
const detected = detectedHostRuntime.resolved || detectedHostRuntime.id || 'none'
|
|
124
|
+
const reason = `${detectedHostRuntime.reason ?? ''}`.trim()
|
|
125
|
+
throw new Error(
|
|
126
|
+
`AgentSquared gateway startup currently supports only the OpenClaw host runtime. Detected host runtime: ${detected}.${reason ? ` Detection reason: ${reason}.` : ''} This gateway will not start in a runtime-without-adapter mode. Finish configuring OpenClaw and then retry.`
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
const detectedOpenClawAgent = clean(resolveOpenClawAgentSelection(detectedHostRuntime).defaultAgentId)
|
|
130
|
+
const resolvedOpenClawAgent = openclawAgent || detectedOpenClawAgent
|
|
131
|
+
if (!resolvedOpenClawAgent) {
|
|
132
|
+
throw new Error('OpenClaw host runtime was detected, but no default OpenClaw agent id could be resolved. AgentSquared gateway startup stops here instead of guessing with the AgentSquared id.')
|
|
133
|
+
}
|
|
134
|
+
const runtimeMode = resolvedHostRuntime !== 'none' ? 'host' : 'none'
|
|
135
|
+
const ownerNotifyMode = resolvedHostRuntime !== 'none' ? 'host' : 'inbox'
|
|
136
|
+
const localRuntimeExecutor = createLocalRuntimeExecutor({
|
|
137
|
+
agentId,
|
|
138
|
+
mode: runtimeMode,
|
|
139
|
+
hostRuntime: resolvedHostRuntime,
|
|
140
|
+
conversationStore,
|
|
141
|
+
openclawStateDir: defaultOpenClawStateDir(keyFile, agentId),
|
|
142
|
+
openclawCommand,
|
|
143
|
+
openclawCwd,
|
|
144
|
+
openclawConfigPath,
|
|
145
|
+
openclawAgent: resolvedOpenClawAgent,
|
|
146
|
+
openclawSessionPrefix,
|
|
147
|
+
openclawTimeoutMs,
|
|
148
|
+
openclawGatewayUrl,
|
|
149
|
+
openclawGatewayToken,
|
|
150
|
+
openclawGatewayPassword
|
|
151
|
+
})
|
|
152
|
+
const ownerNotifier = createOwnerNotifier({
|
|
153
|
+
agentId,
|
|
154
|
+
mode: ownerNotifyMode,
|
|
155
|
+
hostRuntime: resolvedHostRuntime,
|
|
156
|
+
inbox: inboxStore,
|
|
157
|
+
openclawStateDir: defaultOpenClawStateDir(keyFile, agentId),
|
|
158
|
+
openclawCommand,
|
|
159
|
+
openclawCwd,
|
|
160
|
+
openclawConfigPath,
|
|
161
|
+
openclawAgent: resolvedOpenClawAgent,
|
|
162
|
+
openclawSessionPrefix,
|
|
163
|
+
openclawTimeoutMs,
|
|
164
|
+
openclawGatewayUrl,
|
|
165
|
+
openclawGatewayToken,
|
|
166
|
+
openclawGatewayPassword
|
|
167
|
+
})
|
|
168
|
+
const startupChecks = {
|
|
169
|
+
relay: { ok: false, error: '' },
|
|
170
|
+
hostRuntime: { ok: false, error: '' }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const binding = await getBindingDocument(apiBase)
|
|
174
|
+
startupChecks.relay = { ok: true, error: '' }
|
|
175
|
+
const relayListenAddrs = buildRelayListenAddrs(binding.relayMultiaddrs ?? [])
|
|
176
|
+
const requireRelayReservation = relayListenAddrs.length > 0
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const preflight = await localRuntimeExecutor.preflight?.()
|
|
180
|
+
startupChecks.hostRuntime = {
|
|
181
|
+
ok: Boolean(preflight?.ok ?? resolvedHostRuntime === 'none'),
|
|
182
|
+
error: preflight?.ok === false ? clean(preflight?.error || preflight?.reason || 'host runtime preflight failed') : ''
|
|
183
|
+
}
|
|
184
|
+
if (resolvedHostRuntime !== 'none' && startupChecks.hostRuntime.ok === false) {
|
|
185
|
+
throw new Error(`host runtime preflight failed: ${startupChecks.hostRuntime.error}`)
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
startupChecks.hostRuntime = {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: clean(error?.message) || 'host runtime preflight failed'
|
|
191
|
+
}
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let gatewayBase = `http://${gatewayHost}:${gatewayPort}`
|
|
196
|
+
let actualGatewayPort = gatewayPort
|
|
197
|
+
let currentNode = null
|
|
198
|
+
let online = null
|
|
199
|
+
let relayControl = {
|
|
200
|
+
ok: false,
|
|
201
|
+
checkedAt: '',
|
|
202
|
+
detail: 'Relay control-plane handshake has not completed yet.',
|
|
203
|
+
path: '/api/relay/friends'
|
|
204
|
+
}
|
|
205
|
+
let recoveryPromise = null
|
|
206
|
+
let stopping = false
|
|
207
|
+
let activeOperations = 0
|
|
208
|
+
const idleWaiters = []
|
|
209
|
+
const integratedRouter = routerMode === 'integrated'
|
|
210
|
+
? createAgentRouter({
|
|
211
|
+
maxActiveMailboxes,
|
|
212
|
+
routerSkills,
|
|
213
|
+
defaultSkill,
|
|
214
|
+
executeInbound: localRuntimeExecutor,
|
|
215
|
+
notifyOwner: ownerNotifier,
|
|
216
|
+
onRespond(item, result) {
|
|
217
|
+
runtimeState.respondInbound({
|
|
218
|
+
inboundId: item.inboundId,
|
|
219
|
+
result
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
onReject(item, payload) {
|
|
223
|
+
runtimeState.rejectInbound({
|
|
224
|
+
inboundId: item.inboundId,
|
|
225
|
+
code: payload.code,
|
|
226
|
+
message: payload.message
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
: null
|
|
231
|
+
const lifecycle = {
|
|
232
|
+
generation: 0,
|
|
233
|
+
recovering: false,
|
|
234
|
+
lastRecoveryAt: '',
|
|
235
|
+
lastRecoveryReason: '',
|
|
236
|
+
lastHealthyAt: '',
|
|
237
|
+
lastError: '',
|
|
238
|
+
consecutiveFailures: 0,
|
|
239
|
+
routerMode
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function flushIdleWaiters() {
|
|
243
|
+
if (activeOperations !== 0) {
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
while (idleWaiters.length > 0) {
|
|
247
|
+
const resolve = idleWaiters.shift()
|
|
248
|
+
resolve?.(true)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function beginOperation() {
|
|
253
|
+
activeOperations += 1
|
|
254
|
+
let done = false
|
|
255
|
+
return () => {
|
|
256
|
+
if (done) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
done = true
|
|
260
|
+
activeOperations = Math.max(0, activeOperations - 1)
|
|
261
|
+
flushIdleWaiters()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function waitForIdle(timeoutMs) {
|
|
266
|
+
if (activeOperations === 0) {
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
const index = idleWaiters.indexOf(onIdle)
|
|
272
|
+
if (index >= 0) {
|
|
273
|
+
idleWaiters.splice(index, 1)
|
|
274
|
+
}
|
|
275
|
+
resolve(false)
|
|
276
|
+
}, timeoutMs)
|
|
277
|
+
const onIdle = () => {
|
|
278
|
+
clearTimeout(timer)
|
|
279
|
+
resolve(true)
|
|
280
|
+
}
|
|
281
|
+
idleWaiters.push(onIdle)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function markHealthy() {
|
|
286
|
+
lifecycle.lastHealthyAt = nowISO()
|
|
287
|
+
lifecycle.lastError = ''
|
|
288
|
+
lifecycle.consecutiveFailures = 0
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function noteFailure(error) {
|
|
292
|
+
lifecycle.lastError = error?.message ?? `${error ?? 'gateway failure'}`
|
|
293
|
+
lifecycle.consecutiveFailures += 1
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function buildLifecycleSnapshot() {
|
|
297
|
+
return {
|
|
298
|
+
...lifecycle,
|
|
299
|
+
activeOperations,
|
|
300
|
+
hasNode: Boolean(currentNode),
|
|
301
|
+
online
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function updateRelayControl(ok, detail) {
|
|
306
|
+
relayControl = {
|
|
307
|
+
ok: Boolean(ok),
|
|
308
|
+
checkedAt: nowISO(),
|
|
309
|
+
detail: `${detail ?? ''}`.trim() || (ok ? 'Relay control-plane handshake succeeded.' : 'Relay control-plane handshake failed.'),
|
|
310
|
+
path: '/api/relay/friends'
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function relayControlIsFresh() {
|
|
315
|
+
if (!relayControl.checkedAt) {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
const parsed = Date.parse(relayControl.checkedAt)
|
|
319
|
+
if (!Number.isFinite(parsed)) {
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
return (Date.now() - parsed) <= relayControlCheckMs
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildRouterSnapshot() {
|
|
326
|
+
return integratedRouter
|
|
327
|
+
? integratedRouter.snapshot()
|
|
328
|
+
: {
|
|
329
|
+
mode: 'external',
|
|
330
|
+
routerSkills,
|
|
331
|
+
defaultSkill,
|
|
332
|
+
runtimeMode: localRuntimeExecutor.mode,
|
|
333
|
+
ownerReportMode: ownerNotifier.mode,
|
|
334
|
+
hostRuntime: resolvedHostRuntime
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function writeStateForNode(node) {
|
|
339
|
+
if (!gatewayBase || !node?.peerId?.toString?.()) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
writeGatewayState(gatewayStateFile, {
|
|
343
|
+
agentId,
|
|
344
|
+
gatewayBase,
|
|
345
|
+
gatewayPid: process.pid,
|
|
346
|
+
gatewayHost,
|
|
347
|
+
gatewayPort: actualGatewayPort,
|
|
348
|
+
keyFile: path.resolve(keyFile),
|
|
349
|
+
peerKeyFile: path.resolve(peerKeyFile),
|
|
350
|
+
peerId: node.peerId.toString(),
|
|
351
|
+
runtimePackageVersion: currentRuntime.packageVersion,
|
|
352
|
+
runtimeGitCommit: currentRuntime.gitCommit,
|
|
353
|
+
runtimeRepoUrl: currentRuntime.repoUrl,
|
|
354
|
+
skillsPackageVersion: currentRuntime.packageVersion,
|
|
355
|
+
skillsGitCommit: currentRuntime.gitCommit,
|
|
356
|
+
skillsRepoUrl: currentRuntime.repoUrl,
|
|
357
|
+
runtimeRevision,
|
|
358
|
+
runtimeStartedAt: nowISO(),
|
|
359
|
+
updatedAt: nowISO()
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function stopNode(node) {
|
|
364
|
+
if (!node) {
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
await node.stop()
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error(`gateway node stop failed: ${error.message}`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function createAttachedNode() {
|
|
375
|
+
const node = await createNode({
|
|
376
|
+
listenAddrs,
|
|
377
|
+
relayListenAddrs,
|
|
378
|
+
peerKeyFile
|
|
379
|
+
})
|
|
380
|
+
await attachInboundRouter({
|
|
381
|
+
apiBase,
|
|
382
|
+
agentId,
|
|
383
|
+
bundle,
|
|
384
|
+
node,
|
|
385
|
+
binding,
|
|
386
|
+
sessionStore: runtimeState
|
|
387
|
+
})
|
|
388
|
+
return node
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function verifyTransport(node, timeoutMs = transportCheckTimeoutMs) {
|
|
392
|
+
const transport = await currentTransport(node, binding, {
|
|
393
|
+
requireRelayReservation,
|
|
394
|
+
timeoutMs
|
|
395
|
+
})
|
|
396
|
+
markHealthy()
|
|
397
|
+
return transport
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function verifyRelayControlPlane(node, { force = false } = {}) {
|
|
401
|
+
if (!force && relayControl.ok && relayControlIsFresh()) {
|
|
402
|
+
return relayControl
|
|
403
|
+
}
|
|
404
|
+
const transport = await verifyTransport(node)
|
|
405
|
+
try {
|
|
406
|
+
await getFriendDirectory(apiBase, agentId, bundle, transport)
|
|
407
|
+
updateRelayControl(true, 'Signed relay MCP handshake succeeded and relay accepted the current transport.')
|
|
408
|
+
markHealthy()
|
|
409
|
+
return relayControl
|
|
410
|
+
} catch (error) {
|
|
411
|
+
updateRelayControl(false, error?.message ?? 'Relay control-plane handshake failed.')
|
|
412
|
+
throw error
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function publishPresence(node, activitySummary = 'Gateway listener ready for trusted direct sessions.') {
|
|
417
|
+
const value = await publishGatewayPresence(
|
|
418
|
+
apiBase,
|
|
419
|
+
agentId,
|
|
420
|
+
bundle,
|
|
421
|
+
node,
|
|
422
|
+
binding,
|
|
423
|
+
activitySummary,
|
|
424
|
+
{ requireRelayReservation }
|
|
425
|
+
)
|
|
426
|
+
online = value
|
|
427
|
+
updateRelayControl(true, 'Signed relay presence publish succeeded.')
|
|
428
|
+
markHealthy()
|
|
429
|
+
return value
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function recoverGateway(reason) {
|
|
433
|
+
if (stopping) {
|
|
434
|
+
throw new Error('gateway is stopping')
|
|
435
|
+
}
|
|
436
|
+
if (recoveryPromise) {
|
|
437
|
+
return recoveryPromise
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
recoveryPromise = (async () => {
|
|
441
|
+
lifecycle.recovering = true
|
|
442
|
+
lifecycle.lastRecoveryAt = nowISO()
|
|
443
|
+
lifecycle.lastRecoveryReason = `${reason}`.trim() || 'gateway recovery'
|
|
444
|
+
|
|
445
|
+
const previousNode = currentNode
|
|
446
|
+
currentNode = null
|
|
447
|
+
online = null
|
|
448
|
+
|
|
449
|
+
await waitForIdle(recoveryIdleWaitMs)
|
|
450
|
+
runtimeState.reset({
|
|
451
|
+
reason: `gateway reconnect in progress: ${lifecycle.lastRecoveryReason}`,
|
|
452
|
+
preserveTrustedSessions: true
|
|
453
|
+
})
|
|
454
|
+
await stopNode(previousNode)
|
|
455
|
+
|
|
456
|
+
let nextNode = null
|
|
457
|
+
try {
|
|
458
|
+
nextNode = await createAttachedNode()
|
|
459
|
+
await publishPresence(nextNode)
|
|
460
|
+
await verifyRelayControlPlane(nextNode, { force: true })
|
|
461
|
+
currentNode = nextNode
|
|
462
|
+
lifecycle.generation += 1
|
|
463
|
+
lifecycle.recovering = false
|
|
464
|
+
writeStateForNode(nextNode)
|
|
465
|
+
const startupHealth = {
|
|
466
|
+
agentId,
|
|
467
|
+
gatewayBase,
|
|
468
|
+
runtimeRevision,
|
|
469
|
+
hostRuntime: detectedHostRuntime,
|
|
470
|
+
startupChecks,
|
|
471
|
+
relayControl,
|
|
472
|
+
peerId: nextNode.peerId.toString(),
|
|
473
|
+
listenAddrs: directListenAddrs(nextNode),
|
|
474
|
+
relayAddrs: relayReservationAddrs(nextNode),
|
|
475
|
+
streamProtocol: binding.streamProtocol,
|
|
476
|
+
supportedBindings: binding.supportedBindings ?? [],
|
|
477
|
+
routerMode,
|
|
478
|
+
agentRouter: buildRouterSnapshot(),
|
|
479
|
+
lifecycle: buildLifecycleSnapshot(),
|
|
480
|
+
runtimeState: runtimeState.snapshot(),
|
|
481
|
+
conversations: conversationStore.snapshot(),
|
|
482
|
+
inbox: inboxStore.snapshot()
|
|
483
|
+
}
|
|
484
|
+
const standardReport = buildStandardRuntimeReport({
|
|
485
|
+
apiBase,
|
|
486
|
+
agentId,
|
|
487
|
+
keyFile,
|
|
488
|
+
detectedHostRuntime,
|
|
489
|
+
gateway: {
|
|
490
|
+
started: true,
|
|
491
|
+
gatewayBase
|
|
492
|
+
},
|
|
493
|
+
gatewayHealth: startupHealth,
|
|
494
|
+
previousState: previousGatewayState
|
|
495
|
+
})
|
|
496
|
+
const ownerFacingLines = buildStandardRuntimeOwnerLines(standardReport)
|
|
497
|
+
console.log(JSON.stringify({
|
|
498
|
+
event: lifecycle.generation === 1 ? 'gateway-started' : 'gateway-recovered',
|
|
499
|
+
agentId,
|
|
500
|
+
gatewayBase,
|
|
501
|
+
gatewayStateFile,
|
|
502
|
+
peerId: nextNode.peerId.toString(),
|
|
503
|
+
listenAddrs: directListenAddrs(nextNode),
|
|
504
|
+
relayAddrs: relayReservationAddrs(nextNode),
|
|
505
|
+
streamProtocol: binding.streamProtocol,
|
|
506
|
+
peerKeyFile,
|
|
507
|
+
routerMode,
|
|
508
|
+
agentRouter: buildRouterSnapshot(),
|
|
509
|
+
lifecycle: buildLifecycleSnapshot(),
|
|
510
|
+
runtimeState: runtimeState.snapshot(),
|
|
511
|
+
conversations: conversationStore.snapshot(),
|
|
512
|
+
standardReport,
|
|
513
|
+
ownerFacingLines,
|
|
514
|
+
ownerFacingText: toOwnerFacingText(ownerFacingLines)
|
|
515
|
+
}, null, 2))
|
|
516
|
+
return nextNode
|
|
517
|
+
} catch (error) {
|
|
518
|
+
lifecycle.lastError = error.message
|
|
519
|
+
if (nextNode) {
|
|
520
|
+
await stopNode(nextNode)
|
|
521
|
+
}
|
|
522
|
+
throw error
|
|
523
|
+
} finally {
|
|
524
|
+
lifecycle.recovering = false
|
|
525
|
+
recoveryPromise = null
|
|
526
|
+
}
|
|
527
|
+
})()
|
|
528
|
+
|
|
529
|
+
return recoveryPromise
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function ensureGatewayReady(reason) {
|
|
533
|
+
if (recoveryPromise) {
|
|
534
|
+
await recoveryPromise
|
|
535
|
+
}
|
|
536
|
+
if (!currentNode) {
|
|
537
|
+
await recoverGateway(reason)
|
|
538
|
+
}
|
|
539
|
+
if (!currentNode) {
|
|
540
|
+
throw new Error('gateway transport is unavailable')
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await verifyTransport(currentNode)
|
|
544
|
+
await verifyRelayControlPlane(currentNode)
|
|
545
|
+
return currentNode
|
|
546
|
+
} catch (error) {
|
|
547
|
+
noteFailure(error)
|
|
548
|
+
await recoverGateway(`${reason}: ${error.message}`)
|
|
549
|
+
if (!currentNode) {
|
|
550
|
+
throw new Error(`gateway transport is unavailable: ${error.message}`)
|
|
551
|
+
}
|
|
552
|
+
await verifyTransport(currentNode)
|
|
553
|
+
await verifyRelayControlPlane(currentNode, { force: true })
|
|
554
|
+
return currentNode
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function runHealthCheck(source) {
|
|
559
|
+
if (recoveryPromise || stopping) {
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
if (!currentNode) {
|
|
563
|
+
try {
|
|
564
|
+
await recoverGateway(`${source}: no active gateway node`)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
noteFailure(error)
|
|
567
|
+
console.error(`${source} recovery failed: ${error.message}`)
|
|
568
|
+
}
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
await verifyTransport(currentNode)
|
|
573
|
+
await verifyRelayControlPlane(currentNode)
|
|
574
|
+
} catch (error) {
|
|
575
|
+
noteFailure(error)
|
|
576
|
+
console.error(`${source} failed: ${error.message}`)
|
|
577
|
+
if (lifecycle.consecutiveFailures >= failuresBeforeRecover) {
|
|
578
|
+
try {
|
|
579
|
+
await recoverGateway(`${source}: ${error.message}`)
|
|
580
|
+
} catch (recoveryError) {
|
|
581
|
+
noteFailure(recoveryError)
|
|
582
|
+
console.error(`${source} recovery failed: ${recoveryError.message}`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function runIntegratedRouterLoop() {
|
|
589
|
+
if (!integratedRouter) {
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
while (!stopping) {
|
|
593
|
+
const item = await runtimeState.nextInbound({ waitMs: routerWaitMs })
|
|
594
|
+
if (!item?.inboundId) {
|
|
595
|
+
continue
|
|
596
|
+
}
|
|
597
|
+
integratedRouter.enqueue(item).catch((error) => {
|
|
598
|
+
try {
|
|
599
|
+
runtimeState.rejectInbound({
|
|
600
|
+
inboundId: item.inboundId,
|
|
601
|
+
code: Number.parseInt(`${error?.code ?? 500}`, 10) || 500,
|
|
602
|
+
message: error?.message ?? 'integrated agent router failed to process inbound request'
|
|
603
|
+
})
|
|
604
|
+
} catch (rejectError) {
|
|
605
|
+
console.error(rejectError.message)
|
|
606
|
+
}
|
|
607
|
+
console.error(error?.message ?? 'integrated agent router failed to process inbound request')
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const server = http.createServer(async (req, res) => {
|
|
613
|
+
try {
|
|
614
|
+
const url = new URL(req.url ?? '/', gatewayBase)
|
|
615
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
616
|
+
const state = readGatewayState(gatewayStateFile)
|
|
617
|
+
let revisionStatus
|
|
618
|
+
try {
|
|
619
|
+
revisionStatus = assertGatewayStateFresh(state, gatewayStateFile)
|
|
620
|
+
} catch (error) {
|
|
621
|
+
revisionStatus = {
|
|
622
|
+
stale: true,
|
|
623
|
+
expectedRevision: runtimeRevision,
|
|
624
|
+
currentRevision: `${state?.runtimeRevision ?? ''}`.trim(),
|
|
625
|
+
message: error.message
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (recoveryPromise || !currentNode) {
|
|
629
|
+
jsonResponse(res, 503, {
|
|
630
|
+
agentId,
|
|
631
|
+
gatewayBase,
|
|
632
|
+
gatewayStateFile,
|
|
633
|
+
runtimeRevision,
|
|
634
|
+
revisionStatus,
|
|
635
|
+
hostRuntime: detectedHostRuntime,
|
|
636
|
+
startupChecks,
|
|
637
|
+
relayControl,
|
|
638
|
+
routerMode,
|
|
639
|
+
agentRouter: buildRouterSnapshot(),
|
|
640
|
+
lifecycle: buildLifecycleSnapshot(),
|
|
641
|
+
runtimeState: runtimeState.snapshot(),
|
|
642
|
+
conversations: conversationStore.snapshot(),
|
|
643
|
+
inbox: inboxStore.snapshot()
|
|
644
|
+
})
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const transport = await verifyTransport(currentNode)
|
|
649
|
+
await verifyRelayControlPlane(currentNode, { force: true })
|
|
650
|
+
jsonResponse(res, 200, {
|
|
651
|
+
agentId,
|
|
652
|
+
gatewayBase,
|
|
653
|
+
gatewayStateFile,
|
|
654
|
+
runtimeRevision,
|
|
655
|
+
revisionStatus,
|
|
656
|
+
hostRuntime: detectedHostRuntime,
|
|
657
|
+
startupChecks,
|
|
658
|
+
relayControl,
|
|
659
|
+
peerId: transport.peerId,
|
|
660
|
+
listenAddrs: transport.listenAddrs,
|
|
661
|
+
relayAddrs: transport.relayAddrs,
|
|
662
|
+
directListenAddrs: directListenAddrs(currentNode),
|
|
663
|
+
relayReservationAddrs: relayReservationAddrs(currentNode),
|
|
664
|
+
streamProtocol: transport.streamProtocol,
|
|
665
|
+
supportedBindings: transport.supportedBindings,
|
|
666
|
+
routerMode,
|
|
667
|
+
agentRouter: buildRouterSnapshot(),
|
|
668
|
+
lifecycle: buildLifecycleSnapshot(),
|
|
669
|
+
runtimeState: runtimeState.snapshot(),
|
|
670
|
+
conversations: conversationStore.snapshot(),
|
|
671
|
+
inbox: inboxStore.snapshot()
|
|
672
|
+
})
|
|
673
|
+
return
|
|
674
|
+
} catch (error) {
|
|
675
|
+
noteFailure(error)
|
|
676
|
+
jsonResponse(res, 503, {
|
|
677
|
+
agentId,
|
|
678
|
+
gatewayBase,
|
|
679
|
+
gatewayStateFile,
|
|
680
|
+
runtimeRevision,
|
|
681
|
+
revisionStatus,
|
|
682
|
+
hostRuntime: detectedHostRuntime,
|
|
683
|
+
startupChecks,
|
|
684
|
+
relayControl,
|
|
685
|
+
routerMode,
|
|
686
|
+
error: { message: error.message },
|
|
687
|
+
agentRouter: buildRouterSnapshot(),
|
|
688
|
+
lifecycle: buildLifecycleSnapshot(),
|
|
689
|
+
runtimeState: runtimeState.snapshot(),
|
|
690
|
+
conversations: conversationStore.snapshot(),
|
|
691
|
+
inbox: inboxStore.snapshot()
|
|
692
|
+
})
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (req.method === 'GET' && url.pathname === '/inbox/index') {
|
|
698
|
+
jsonResponse(res, 200, {
|
|
699
|
+
index: inboxStore.readIndex(),
|
|
700
|
+
snapshot: inboxStore.snapshot()
|
|
701
|
+
})
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (req.method === 'GET' && url.pathname === '/inbound/next') {
|
|
706
|
+
if (integratedRouter) {
|
|
707
|
+
jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound polling is disabled' } })
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
const waitMs = Number.parseInt(url.searchParams.get('waitMs') ?? '30000', 10)
|
|
711
|
+
const nextInbound = await runtimeState.nextInbound({ waitMs })
|
|
712
|
+
jsonResponse(res, 200, { item: nextInbound })
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (req.method === 'POST' && url.pathname === '/inbound/respond') {
|
|
717
|
+
if (integratedRouter) {
|
|
718
|
+
jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound responses are disabled' } })
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
const body = await readJson(req)
|
|
722
|
+
runtimeState.respondInbound({
|
|
723
|
+
inboundId: requireArg(body.inboundId, 'inboundId is required'),
|
|
724
|
+
result: body.result ?? {}
|
|
725
|
+
})
|
|
726
|
+
jsonResponse(res, 200, { ok: true })
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (req.method === 'POST' && url.pathname === '/inbound/reject') {
|
|
731
|
+
if (integratedRouter) {
|
|
732
|
+
jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound rejects are disabled' } })
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
const body = await readJson(req)
|
|
736
|
+
runtimeState.rejectInbound({
|
|
737
|
+
inboundId: requireArg(body.inboundId, 'inboundId is required'),
|
|
738
|
+
code: Number.parseInt(body.code ?? '500', 10) || 500,
|
|
739
|
+
message: `${body.message ?? 'local runtime rejected the inbound request'}`
|
|
740
|
+
})
|
|
741
|
+
jsonResponse(res, 200, { ok: true })
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (req.method === 'POST' && url.pathname === '/connect') {
|
|
746
|
+
const node = await ensureGatewayReady('connect request')
|
|
747
|
+
const releaseOperation = beginOperation()
|
|
748
|
+
try {
|
|
749
|
+
const body = await readJson(req)
|
|
750
|
+
const result = await openDirectPeerSession({
|
|
751
|
+
apiBase,
|
|
752
|
+
agentId,
|
|
753
|
+
bundle,
|
|
754
|
+
node,
|
|
755
|
+
binding,
|
|
756
|
+
targetAgentId: requireArg(body.targetAgentId, 'targetAgentId is required'),
|
|
757
|
+
skillName: (body.skillHint ?? body.skillName ?? '').trim(),
|
|
758
|
+
method: requireArg(body.method, 'method is required'),
|
|
759
|
+
message: body.message,
|
|
760
|
+
metadata: body.metadata ?? null,
|
|
761
|
+
activitySummary: (body.activitySummary ?? '').trim() || `Preparing direct peer session${(body.skillHint ?? body.skillName ?? '').trim() ? ` for ${(body.skillHint ?? body.skillName ?? '').trim()}` : ''}.`,
|
|
762
|
+
report: body.report ?? null,
|
|
763
|
+
sessionStore: runtimeState
|
|
764
|
+
})
|
|
765
|
+
jsonResponse(res, 200, result)
|
|
766
|
+
return
|
|
767
|
+
} finally {
|
|
768
|
+
releaseOperation()
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
jsonResponse(res, 404, { error: { message: 'Not found' } })
|
|
773
|
+
} catch (error) {
|
|
774
|
+
jsonResponse(res, 500, { error: { message: error.message } })
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
await new Promise((resolve, reject) => {
|
|
779
|
+
server.once('error', reject)
|
|
780
|
+
server.listen(gatewayPort, gatewayHost, resolve)
|
|
781
|
+
})
|
|
782
|
+
const controlAddress = server.address()
|
|
783
|
+
actualGatewayPort = typeof controlAddress === 'object' && controlAddress ? controlAddress.port : gatewayPort
|
|
784
|
+
gatewayBase = `http://${gatewayHost}:${actualGatewayPort}`
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
await recoverGateway('initial startup')
|
|
788
|
+
} catch (error) {
|
|
789
|
+
noteFailure(error)
|
|
790
|
+
console.error(`gateway initial startup failed: ${error.message}`)
|
|
791
|
+
throw error
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const integratedRouterLoop = integratedRouter
|
|
795
|
+
? runIntegratedRouterLoop().catch((error) => {
|
|
796
|
+
console.error(`integrated agent router stopped: ${error.message}`)
|
|
797
|
+
})
|
|
798
|
+
: null
|
|
799
|
+
|
|
800
|
+
const presenceTimer = presenceRefreshMs > 0
|
|
801
|
+
? setInterval(async () => {
|
|
802
|
+
if (stopping || recoveryPromise) {
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
if (!currentNode) {
|
|
806
|
+
try {
|
|
807
|
+
await recoverGateway('presence refresh found no active gateway node')
|
|
808
|
+
} catch (error) {
|
|
809
|
+
noteFailure(error)
|
|
810
|
+
console.error(`gateway presence recovery failed: ${error.message}`)
|
|
811
|
+
}
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
await publishPresence(currentNode)
|
|
816
|
+
} catch (error) {
|
|
817
|
+
noteFailure(error)
|
|
818
|
+
console.error(`gateway presence refresh failed: ${error.message}`)
|
|
819
|
+
if (lifecycle.consecutiveFailures >= failuresBeforeRecover) {
|
|
820
|
+
try {
|
|
821
|
+
await recoverGateway(`presence refresh failed: ${error.message}`)
|
|
822
|
+
} catch (recoveryError) {
|
|
823
|
+
noteFailure(recoveryError)
|
|
824
|
+
console.error(`gateway presence recovery failed: ${recoveryError.message}`)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}, presenceRefreshMs)
|
|
829
|
+
: null
|
|
830
|
+
|
|
831
|
+
const healthTimer = setInterval(async () => {
|
|
832
|
+
await runHealthCheck('gateway transport watchdog')
|
|
833
|
+
}, healthCheckMs)
|
|
834
|
+
|
|
835
|
+
const stop = async () => {
|
|
836
|
+
if (stopping) {
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
stopping = true
|
|
840
|
+
if (presenceTimer) {
|
|
841
|
+
clearInterval(presenceTimer)
|
|
842
|
+
}
|
|
843
|
+
clearInterval(healthTimer)
|
|
844
|
+
await sleep(10)
|
|
845
|
+
if (recoveryPromise) {
|
|
846
|
+
try {
|
|
847
|
+
await recoveryPromise
|
|
848
|
+
} catch {
|
|
849
|
+
// best-effort shutdown only
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (integratedRouter) {
|
|
853
|
+
try {
|
|
854
|
+
await integratedRouter.whenIdle()
|
|
855
|
+
} catch {
|
|
856
|
+
// best-effort shutdown only
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
await new Promise((resolve) => server.close(resolve))
|
|
860
|
+
await stopNode(currentNode)
|
|
861
|
+
process.exit(0)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
process.on('SIGINT', () => {
|
|
865
|
+
stop().catch((error) => {
|
|
866
|
+
console.error(error.message)
|
|
867
|
+
process.exit(1)
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
process.on('SIGTERM', () => {
|
|
871
|
+
stop().catch((error) => {
|
|
872
|
+
console.error(error.message)
|
|
873
|
+
process.exit(1)
|
|
874
|
+
})
|
|
875
|
+
})
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
|
879
|
+
runGateway(process.argv.slice(2)).catch((error) => {
|
|
880
|
+
console.error(error.message)
|
|
881
|
+
process.exit(1)
|
|
882
|
+
})
|
|
883
|
+
}
|