@drisp/cli 0.4.2 → 0.4.4

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.
@@ -23,7 +23,6 @@ import {
23
23
  } from "./chunk-4CRZXLIP.js";
24
24
 
25
25
  // src/gateway/daemon.ts
26
- import crypto from "crypto";
27
26
  import fs3 from "fs";
28
27
 
29
28
  // src/gateway/adapters/console/adapter.ts
@@ -35,6 +34,8 @@ import { WebSocket } from "ws";
35
34
  var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
36
35
  var DEFAULT_INITIAL_RECONNECT_MS = 1e3;
37
36
  var DEFAULT_MAX_RECONNECT_MS = 3e4;
37
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
38
+ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 9e4;
38
39
  function createConsoleBrokerClient(opts) {
39
40
  const hasStatic = typeof opts.pairingToken === "string" && opts.pairingToken.length > 0;
40
41
  const hasProvider = typeof opts.pairingTokenProvider === "function";
@@ -46,6 +47,8 @@ function createConsoleBrokerClient(opts) {
46
47
  const provider = hasStatic ? async () => opts.pairingToken : opts.pairingTokenProvider;
47
48
  const initialDelay = opts.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_MS;
48
49
  const maxDelay = opts.reconnect?.maxDelayMs ?? DEFAULT_MAX_RECONNECT_MS;
50
+ const heartbeatInterval = opts.heartbeat?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
51
+ const heartbeatTimeout = opts.heartbeat?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
49
52
  let ws = null;
50
53
  let ready = null;
51
54
  let closeRequested = false;
@@ -53,6 +56,8 @@ function createConsoleBrokerClient(opts) {
53
56
  let reconnectTimer = null;
54
57
  let lastHello = null;
55
58
  let currentToken = null;
59
+ let heartbeatTimer = null;
60
+ let lastPongAt = null;
56
61
  const frameHandlers = /* @__PURE__ */ new Set();
57
62
  const closeHandlers = /* @__PURE__ */ new Set();
58
63
  const readyHandlers = /* @__PURE__ */ new Set();
@@ -61,6 +66,44 @@ function createConsoleBrokerClient(opts) {
61
66
  if (!currentToken) return message;
62
67
  return message.split(currentToken).join(tokenRedacted);
63
68
  }
69
+ function stopHeartbeat() {
70
+ if (heartbeatTimer) {
71
+ clearInterval(heartbeatTimer);
72
+ heartbeatTimer = null;
73
+ }
74
+ lastPongAt = null;
75
+ }
76
+ function startHeartbeat(currentWs) {
77
+ stopHeartbeat();
78
+ lastPongAt = Date.now();
79
+ heartbeatTimer = setInterval(() => {
80
+ if (currentWs !== ws || currentWs.readyState !== WebSocket.OPEN) {
81
+ stopHeartbeat();
82
+ return;
83
+ }
84
+ if (lastPongAt !== null && Date.now() - lastPongAt >= heartbeatTimeout) {
85
+ opts.log(
86
+ "warn",
87
+ "console broker: heartbeat watchdog fired, no pong received \u2014 terminating"
88
+ );
89
+ stopHeartbeat();
90
+ try {
91
+ currentWs.terminate();
92
+ } catch {
93
+ }
94
+ return;
95
+ }
96
+ const ping = {
97
+ kind: "console.ping",
98
+ frameId: makeFrameId(),
99
+ sentAt: Date.now()
100
+ };
101
+ try {
102
+ currentWs.send(JSON.stringify(ping));
103
+ } catch {
104
+ }
105
+ }, heartbeatInterval);
106
+ }
64
107
  function emitClose(reason) {
65
108
  for (const h of [...closeHandlers]) {
66
109
  try {
@@ -216,6 +259,10 @@ function createConsoleBrokerClient(opts) {
216
259
  );
217
260
  return;
218
261
  }
262
+ if (parsed.kind === "console.pong") {
263
+ lastPongAt = Date.now();
264
+ return;
265
+ }
219
266
  for (const h of [...frameHandlers]) {
220
267
  try {
221
268
  h(parsed);
@@ -240,12 +287,14 @@ function createConsoleBrokerClient(opts) {
240
287
  throw err;
241
288
  }
242
289
  next.on("close", (_code, reasonBuf) => {
290
+ stopHeartbeat();
243
291
  if (next !== ws) return;
244
292
  ws = null;
245
293
  ready = null;
246
294
  emitClose(reasonBuf.toString() || "closed");
247
295
  if (!closeRequested) scheduleReconnect();
248
296
  });
297
+ startHeartbeat(next);
249
298
  }
250
299
  async function connect(hello) {
251
300
  if (ws) throw new Error("console broker client already connected");
@@ -255,6 +304,7 @@ function createConsoleBrokerClient(opts) {
255
304
  }
256
305
  function close(reason) {
257
306
  closeRequested = true;
307
+ stopHeartbeat();
258
308
  if (reconnectTimer) {
259
309
  clearTimeout(reconnectTimer);
260
310
  reconnectTimer = null;
@@ -1873,8 +1923,7 @@ var ChannelManager = class {
1873
1923
  // src/gateway/control/handlers.ts
1874
1924
  import { createRequire } from "module";
1875
1925
 
1876
- // src/gateway/sessionRegistry.ts
1877
- import { randomUUID } from "crypto";
1926
+ // src/gateway/runtimeBindingStore.ts
1878
1927
  var AlreadyRegisteredError = class extends Error {
1879
1928
  code = "already_registered";
1880
1929
  constructor(existing) {
@@ -1891,120 +1940,145 @@ var NotRegisteredError = class extends Error {
1891
1940
  this.name = "NotRegisteredError";
1892
1941
  }
1893
1942
  };
1894
- var UnknownDispatchError = class extends Error {
1895
- code = "unknown_dispatch";
1896
- constructor(id) {
1897
- super(`unknown dispatchId: ${id}`);
1898
- this.name = "UnknownDispatchError";
1899
- }
1900
- };
1901
1943
  function maybeLastRebindAt(value) {
1902
1944
  return value !== void 0 ? { lastRebindAt: value } : {};
1903
1945
  }
1904
- var SessionRegistry = class {
1946
+ var RuntimeBindingStore = class {
1905
1947
  current = null;
1906
- binding = null;
1907
- dispatches = /* @__PURE__ */ new Map();
1908
- idFactory;
1948
+ bindingState = null;
1949
+ staleTimer = null;
1950
+ staleSince = null;
1951
+ gracePeriodMs;
1952
+ observers;
1909
1953
  now;
1910
1954
  constructor(opts = {}) {
1911
- this.idFactory = opts.idFactory ?? randomUUID;
1955
+ this.gracePeriodMs = opts.gracePeriodMs ?? 0;
1956
+ this.observers = opts.observers ?? {};
1912
1957
  this.now = opts.now ?? Date.now;
1913
1958
  }
1914
- register(input) {
1915
- if (this.current) {
1916
- if (this.current.runtimeId === input.runtimeId) {
1917
- this.current = {
1918
- ...this.current,
1919
- defaultAgentId: input.defaultAgentId,
1920
- pid: input.pid
1921
- };
1922
- return this.current;
1923
- }
1959
+ // ── lifecycle ─────────────────────────────────────────────
1960
+ /** Register a runtime and bind its connection in one atomic step. */
1961
+ bind(input) {
1962
+ const previous = this.bindingState;
1963
+ const wasStale = previous?.state === "stale";
1964
+ const staleSince = wasStale ? previous.staleSince : null;
1965
+ if (!this.current) {
1966
+ this.current = {
1967
+ runtimeId: input.runtimeId,
1968
+ defaultAgentId: input.defaultAgentId,
1969
+ pid: input.pid,
1970
+ registeredAt: this.now()
1971
+ };
1972
+ } else if (this.current.runtimeId === input.runtimeId) {
1973
+ this.current = {
1974
+ ...this.current,
1975
+ defaultAgentId: input.defaultAgentId,
1976
+ pid: input.pid
1977
+ };
1978
+ } else {
1924
1979
  throw new AlreadyRegisteredError(this.current);
1925
1980
  }
1926
- this.current = { ...input, registeredAt: this.now() };
1927
- return this.current;
1928
- }
1929
- bindConnection(runtimeId, connectionId) {
1930
- if (!this.current || this.current.runtimeId !== runtimeId) {
1931
- throw new NotRegisteredError();
1932
- }
1933
- const previous = this.binding;
1934
1981
  const now = this.now();
1935
- const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== connectionId);
1982
+ const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== input.connectionId);
1936
1983
  const lastRebindAt = isRebind ? now : previous?.lastRebindAt;
1937
1984
  const epoch = previous ? previous.epoch + (isRebind ? 1 : 0) : 1;
1938
- this.binding = {
1985
+ this.bindingState = {
1939
1986
  state: "active",
1940
- connectionId,
1987
+ connectionId: input.connectionId,
1941
1988
  boundAt: now,
1942
1989
  epoch,
1943
1990
  ...maybeLastRebindAt(lastRebindAt)
1944
1991
  };
1992
+ this.clearStaleTimer();
1993
+ if (wasStale && staleSince !== null) {
1994
+ this.observers.onRuntimeRebind?.({
1995
+ runtimeId: input.runtimeId,
1996
+ gapMs: now - staleSince,
1997
+ epoch: this.bindingState.epoch
1998
+ });
1999
+ }
2000
+ return { registeredAt: this.current.registeredAt };
2001
+ }
2002
+ /** Fully unregister a runtime. Throws NotRegisteredError if id does not match. */
2003
+ unbind(runtimeId) {
2004
+ if (!this.current || this.current.runtimeId !== runtimeId) {
2005
+ throw new NotRegisteredError();
2006
+ }
2007
+ this.current = null;
2008
+ this.bindingState = null;
2009
+ this.clearStaleTimer();
1945
2010
  }
1946
- markConnectionStale(connectionId) {
1947
- if (!this.current || !this.binding || this.binding.connectionId !== connectionId || this.binding.state !== "active") {
2011
+ /**
2012
+ * Called when the transport connection closes.
2013
+ * Returns the runtimeId if the close was for the current binding (caller should
2014
+ * clear the push handle); returns null if the connectionId was not recognised.
2015
+ */
2016
+ notifyConnectionClosed(connectionId) {
2017
+ if (!this.current || !this.bindingState || this.bindingState.connectionId !== connectionId) {
1948
2018
  return null;
1949
2019
  }
1950
- this.binding = {
2020
+ const runtimeId = this.current.runtimeId;
2021
+ const now = this.now();
2022
+ this.bindingState = {
1951
2023
  state: "stale",
1952
2024
  connectionId,
1953
- staleSince: this.now(),
1954
- epoch: this.binding.epoch,
1955
- ...maybeLastRebindAt(this.binding.lastRebindAt)
2025
+ staleSince: now,
2026
+ epoch: this.bindingState.epoch,
2027
+ ...maybeLastRebindAt(this.bindingState.lastRebindAt)
1956
2028
  };
1957
- return this.current.runtimeId;
2029
+ if (this.gracePeriodMs <= 0) {
2030
+ this.current = null;
2031
+ this.bindingState = null;
2032
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2033
+ return runtimeId;
2034
+ }
2035
+ this.staleSince = now;
2036
+ this.staleTimer = setTimeout(() => {
2037
+ this.expireStaleBinding(runtimeId);
2038
+ }, this.gracePeriodMs);
2039
+ return runtimeId;
2040
+ }
2041
+ stop() {
2042
+ this.clearStaleTimer();
1958
2043
  }
2044
+ // ── reads ─────────────────────────────────────────────────
1959
2045
  hasActiveBinding(runtimeId) {
1960
- if (!this.current || !this.binding || this.binding.state !== "active") {
2046
+ if (!this.current || !this.bindingState || this.bindingState.state !== "active") {
1961
2047
  return false;
1962
2048
  }
1963
2049
  return runtimeId === void 0 || this.current.runtimeId === runtimeId;
1964
2050
  }
2051
+ getCurrent() {
2052
+ return this.current;
2053
+ }
1965
2054
  getBinding() {
1966
- return this.binding;
2055
+ return this.bindingState;
1967
2056
  }
1968
2057
  getRuntimeIdByConnection(connectionId) {
1969
- if (!this.current || !this.binding) return null;
1970
- return this.binding.connectionId === connectionId ? this.current.runtimeId : null;
1971
- }
1972
- unregister(runtimeId) {
1973
- if (!this.current || this.current.runtimeId !== runtimeId) {
1974
- throw new NotRegisteredError();
2058
+ if (!this.current || !this.bindingState) return null;
2059
+ return this.bindingState.connectionId === connectionId ? this.current.runtimeId : null;
2060
+ }
2061
+ // ── private ───────────────────────────────────────────────
2062
+ expireStaleBinding(runtimeId) {
2063
+ this.staleTimer = null;
2064
+ const since = this.staleSince;
2065
+ this.staleSince = null;
2066
+ if (!this.current || this.current.runtimeId !== runtimeId || this.hasActiveBinding(runtimeId)) {
2067
+ return;
1975
2068
  }
1976
2069
  this.current = null;
1977
- this.binding = null;
1978
- this.dispatches.clear();
1979
- }
1980
- getCurrent() {
1981
- return this.current;
1982
- }
1983
- beginDispatch(input) {
1984
- if (!this.current) {
1985
- throw new NotRegisteredError();
2070
+ this.bindingState = null;
2071
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2072
+ if (since !== null) {
2073
+ this.observers.onRuntimeExpired?.({ runtimeId, gapMs: this.now() - since });
1986
2074
  }
1987
- const dispatchId = this.idFactory();
1988
- const entry = {
1989
- dispatchId,
1990
- sessionKey: input.sessionKey,
1991
- agentId: input.agentId,
1992
- location: input.location,
1993
- createdAt: this.now()
1994
- };
1995
- this.dispatches.set(dispatchId, entry);
1996
- return entry;
1997
2075
  }
1998
- completeDispatch(dispatchId) {
1999
- const entry = this.dispatches.get(dispatchId);
2000
- if (!entry) {
2001
- throw new UnknownDispatchError(dispatchId);
2076
+ clearStaleTimer() {
2077
+ if (this.staleTimer) {
2078
+ clearTimeout(this.staleTimer);
2079
+ this.staleTimer = null;
2002
2080
  }
2003
- this.dispatches.delete(dispatchId);
2004
- return entry;
2005
- }
2006
- pendingDispatchCount() {
2007
- return this.dispatches.size;
2081
+ this.staleSince = null;
2008
2082
  }
2009
2083
  };
2010
2084
 
@@ -2014,7 +2088,7 @@ var cachedVersion = null;
2014
2088
  function readVersion() {
2015
2089
  if (cachedVersion !== null) return cachedVersion;
2016
2090
  try {
2017
- const injected = "0.4.2";
2091
+ const injected = "0.4.4";
2018
2092
  if (typeof injected === "string" && injected.length > 0) {
2019
2093
  cachedVersion = injected;
2020
2094
  return cachedVersion;
@@ -2029,273 +2103,258 @@ function readVersion() {
2029
2103
  }
2030
2104
  return cachedVersion;
2031
2105
  }
2106
+ var PING = {
2107
+ kind: "ping",
2108
+ handle: (_envelope, { ts, deps }) => {
2109
+ const payload = {
2110
+ pong: true,
2111
+ daemonPid: process.pid,
2112
+ uptimeMs: ts - deps.startedAt
2113
+ };
2114
+ return payload;
2115
+ }
2116
+ };
2117
+ var STATUS = {
2118
+ kind: "status",
2119
+ handle: (_envelope, { ts, deps }) => {
2120
+ const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2121
+ id: c.id,
2122
+ state: c.health?.transportOk === false ? "degraded" : "running",
2123
+ ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2124
+ ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2125
+ }));
2126
+ const payload = {
2127
+ daemonPid: process.pid,
2128
+ startedAt: deps.startedAt,
2129
+ uptimeMs: ts - deps.startedAt,
2130
+ version: readVersion(),
2131
+ listener: deps.getListener?.() ?? {
2132
+ kind: "uds",
2133
+ socketPath: "<unknown>"
2134
+ },
2135
+ channels,
2136
+ runtimes: runtimeStatusEntries(deps.pipeline)
2137
+ };
2138
+ return payload;
2139
+ }
2140
+ };
2141
+ var CHANNELS_RELOAD = {
2142
+ kind: "channels.reload",
2143
+ requires: ["reloadChannels"],
2144
+ unsupportedMessage: "channel reload not configured",
2145
+ handle: async (_envelope, { deps }) => {
2146
+ const payload = await deps.reloadChannels();
2147
+ return payload;
2148
+ }
2149
+ };
2150
+ var SESSION_REGISTER = {
2151
+ kind: "session.register",
2152
+ requires: ["pipeline"],
2153
+ unsupportedMessage: "session.register not configured",
2154
+ handle: (envelope, { deps, connection }) => {
2155
+ const req = envelope.payload;
2156
+ const reg = deps.pipeline.registerRuntime({
2157
+ runtimeId: req.runtimeId,
2158
+ defaultAgentId: req.defaultAgentId,
2159
+ pid: req.pid,
2160
+ connectionId: connection.connectionId,
2161
+ push: connection.push
2162
+ });
2163
+ const payload = {
2164
+ registeredAt: reg.registeredAt,
2165
+ gatewayStartedAt: deps.startedAt
2166
+ };
2167
+ return payload;
2168
+ }
2169
+ };
2170
+ var SESSION_UNREGISTER = {
2171
+ kind: "session.unregister",
2172
+ requires: ["pipeline"],
2173
+ unsupportedMessage: "session.unregister not configured",
2174
+ handle: (envelope, { deps, ts }) => {
2175
+ const req = envelope.payload;
2176
+ deps.pipeline.unregisterRuntime(req.runtimeId);
2177
+ const payload = {
2178
+ unregisteredAt: ts
2179
+ };
2180
+ return payload;
2181
+ }
2182
+ };
2183
+ var SESSION_TURN_COMPLETE = {
2184
+ kind: "session.turn.complete",
2185
+ requires: ["pipeline"],
2186
+ unsupportedMessage: "pipeline not configured",
2187
+ handle: async (envelope, { deps }) => {
2188
+ const req = envelope.payload;
2189
+ return await deps.pipeline.handleTurnComplete(req);
2190
+ }
2191
+ };
2192
+ var CHANNEL_SEND = {
2193
+ kind: "channel.send",
2194
+ requires: ["channelManager"],
2195
+ unsupportedMessage: "channel manager not configured",
2196
+ handle: async (envelope, { deps }) => {
2197
+ const req = envelope.payload;
2198
+ const result = await deps.channelManager.send(
2199
+ req.message.location.channelId,
2200
+ req.message
2201
+ );
2202
+ const payload = {
2203
+ providerMessageId: result.providerMessageId,
2204
+ deliveredAt: result.deliveredAt
2205
+ };
2206
+ return payload;
2207
+ }
2208
+ };
2209
+ var RELAY_PERMISSION_REQUEST = {
2210
+ kind: "relay.permission.request",
2211
+ requires: ["relayCoordinator"],
2212
+ unsupportedMessage: "relay coordinator not configured",
2213
+ requireRegisteredRuntime: true,
2214
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2215
+ const req = envelope.payload;
2216
+ const broadcast = deps.relayCoordinator.requestPermission({
2217
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2218
+ toolName: req.toolName,
2219
+ description: req.description,
2220
+ inputPreview: req.inputPreview,
2221
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2222
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2223
+ });
2224
+ const result = await broadcast.result;
2225
+ const payload = {
2226
+ channelRequestId: broadcast.channelRequestId,
2227
+ result
2228
+ };
2229
+ return payload;
2230
+ }
2231
+ };
2232
+ var RELAY_PERMISSION_CANCEL = {
2233
+ kind: "relay.permission.cancel",
2234
+ requires: ["relayCoordinator"],
2235
+ unsupportedMessage: "relay coordinator not configured",
2236
+ requireRegisteredRuntime: true,
2237
+ handle: (envelope, { deps, callerRuntimeId }) => {
2238
+ const req = envelope.payload;
2239
+ const cancelled = deps.relayCoordinator.cancel(
2240
+ req.channelRequestId,
2241
+ req.reason,
2242
+ callerRuntimeId
2243
+ );
2244
+ const payload = { cancelled };
2245
+ return payload;
2246
+ }
2247
+ };
2248
+ var RELAY_QUESTION_REQUEST = {
2249
+ kind: "relay.question.request",
2250
+ requires: ["relayCoordinator"],
2251
+ unsupportedMessage: "relay coordinator not configured",
2252
+ requireRegisteredRuntime: true,
2253
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2254
+ const req = envelope.payload;
2255
+ const broadcast = deps.relayCoordinator.requestQuestion({
2256
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2257
+ title: req.title,
2258
+ questions: req.questions,
2259
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2260
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2261
+ });
2262
+ const result = await broadcast.result;
2263
+ const payload = {
2264
+ channelRequestId: broadcast.channelRequestId,
2265
+ result
2266
+ };
2267
+ return payload;
2268
+ }
2269
+ };
2270
+ var RELAY_QUESTION_CANCEL = {
2271
+ kind: "relay.question.cancel",
2272
+ requires: ["relayCoordinator"],
2273
+ unsupportedMessage: "relay coordinator not configured",
2274
+ requireRegisteredRuntime: true,
2275
+ handle: (envelope, { deps, callerRuntimeId }) => {
2276
+ const req = envelope.payload;
2277
+ const cancelled = deps.relayCoordinator.cancel(
2278
+ req.channelRequestId,
2279
+ req.reason,
2280
+ callerRuntimeId
2281
+ );
2282
+ const payload = { cancelled };
2283
+ return payload;
2284
+ }
2285
+ };
2286
+ var HANDLERS = new Map(
2287
+ [
2288
+ PING,
2289
+ STATUS,
2290
+ CHANNELS_RELOAD,
2291
+ SESSION_REGISTER,
2292
+ SESSION_UNREGISTER,
2293
+ SESSION_TURN_COMPLETE,
2294
+ CHANNEL_SEND,
2295
+ RELAY_PERMISSION_REQUEST,
2296
+ RELAY_PERMISSION_CANCEL,
2297
+ RELAY_QUESTION_REQUEST,
2298
+ RELAY_QUESTION_CANCEL
2299
+ ].map((spec) => [spec.kind, spec])
2300
+ );
2032
2301
  function createDispatcher(deps) {
2033
2302
  const handle = async (envelope, connection) => {
2034
2303
  const ts = Date.now();
2035
- switch (envelope.kind) {
2036
- case "ping": {
2037
- const payload = {
2038
- pong: true,
2039
- daemonPid: process.pid,
2040
- uptimeMs: ts - deps.startedAt
2041
- };
2042
- return ok(envelope, ts, payload);
2043
- }
2044
- case "status": {
2045
- const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2046
- id: c.id,
2047
- state: c.health?.transportOk === false ? "degraded" : "running",
2048
- ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2049
- ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2050
- }));
2051
- const payload = {
2052
- daemonPid: process.pid,
2053
- startedAt: deps.startedAt,
2054
- uptimeMs: ts - deps.startedAt,
2055
- version: readVersion(),
2056
- listener: deps.getListener?.() ?? {
2057
- kind: "uds",
2058
- socketPath: "<unknown>"
2059
- },
2060
- channels,
2061
- runtimes: runtimeStatusEntries(deps.registry)
2062
- };
2063
- return ok(envelope, ts, payload);
2064
- }
2065
- case "channels.reload": {
2066
- if (!deps.reloadChannels) {
2067
- return error(
2068
- envelope,
2069
- ts,
2070
- "unsupported",
2071
- "channel reload not configured"
2072
- );
2073
- }
2074
- const payload = await deps.reloadChannels();
2075
- return ok(envelope, ts, payload);
2076
- }
2077
- case "session.register": {
2078
- if (!deps.registry)
2079
- return error(
2080
- envelope,
2081
- ts,
2082
- "unsupported",
2083
- "session.register not configured"
2084
- );
2085
- const req = envelope.payload;
2086
- try {
2087
- const reg = deps.registry.register({
2088
- runtimeId: req.runtimeId,
2089
- defaultAgentId: req.defaultAgentId,
2090
- pid: req.pid
2091
- });
2092
- deps.registerRuntimeConnection?.(req.runtimeId, connection);
2093
- try {
2094
- deps.dispatcher?.drainPending();
2095
- } catch (err) {
2096
- process.stderr.write(
2097
- `gateway: drainPending failed: ${err instanceof Error ? err.message : String(err)}
2098
- `
2099
- );
2100
- }
2101
- const payload = {
2102
- registeredAt: reg.registeredAt,
2103
- gatewayStartedAt: deps.startedAt
2104
- };
2105
- return ok(envelope, ts, payload);
2106
- } catch (err) {
2107
- if (err instanceof AlreadyRegisteredError) {
2108
- return error(envelope, ts, err.code, err.message);
2109
- }
2110
- throw err;
2111
- }
2112
- }
2113
- case "session.unregister": {
2114
- if (!deps.registry)
2115
- return error(
2116
- envelope,
2117
- ts,
2118
- "unsupported",
2119
- "session.unregister not configured"
2120
- );
2121
- const req = envelope.payload;
2122
- try {
2123
- deps.registry.unregister(req.runtimeId);
2124
- deps.unregisterRuntimeConnection?.(req.runtimeId);
2125
- const payload = {
2126
- unregisteredAt: ts
2127
- };
2128
- return ok(envelope, ts, payload);
2129
- } catch (err) {
2130
- if (err instanceof NotRegisteredError) {
2131
- return error(envelope, ts, err.code, err.message);
2132
- }
2133
- throw err;
2134
- }
2135
- }
2136
- case "session.turn.complete": {
2137
- if (!deps.dispatcher)
2138
- return error(
2139
- envelope,
2140
- ts,
2141
- "unsupported",
2142
- "dispatcher not configured"
2143
- );
2144
- const req = envelope.payload;
2145
- const result = await deps.dispatcher.handleTurnComplete(req);
2146
- return ok(envelope, ts, result);
2147
- }
2148
- case "channel.send": {
2149
- if (!deps.channelManager)
2150
- return error(
2151
- envelope,
2152
- ts,
2153
- "unsupported",
2154
- "channel manager not configured"
2155
- );
2156
- const req = envelope.payload;
2157
- const result = await deps.channelManager.send(
2158
- req.message.location.channelId,
2159
- req.message
2160
- );
2161
- const payload = {
2162
- providerMessageId: result.providerMessageId,
2163
- deliveredAt: result.deliveredAt
2164
- };
2165
- return ok(envelope, ts, payload);
2166
- }
2167
- case "relay.permission.request": {
2168
- if (!deps.relayCoordinator)
2169
- return error(
2170
- envelope,
2171
- ts,
2172
- "unsupported",
2173
- "relay coordinator not configured"
2174
- );
2175
- const req = envelope.payload;
2176
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2177
- if (deps.registry && callerRuntimeId === void 0) {
2178
- return error(
2179
- envelope,
2180
- ts,
2181
- "not_registered",
2182
- "relay.permission.request requires a registered runtime connection"
2183
- );
2184
- }
2185
- const broadcast = deps.relayCoordinator.requestPermission({
2186
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2187
- toolName: req.toolName,
2188
- description: req.description,
2189
- inputPreview: req.inputPreview,
2190
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2191
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2192
- });
2193
- const result = await broadcast.result;
2194
- const payload = {
2195
- channelRequestId: broadcast.channelRequestId,
2196
- result
2197
- };
2198
- return ok(envelope, Date.now(), payload);
2199
- }
2200
- case "relay.permission.cancel": {
2201
- if (!deps.relayCoordinator)
2202
- return error(
2203
- envelope,
2204
- ts,
2205
- "unsupported",
2206
- "relay coordinator not configured"
2207
- );
2208
- const req = envelope.payload;
2209
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2210
- if (deps.registry && callerRuntimeId === void 0) {
2211
- return error(
2212
- envelope,
2213
- ts,
2214
- "not_registered",
2215
- "relay.permission.cancel requires a registered runtime connection"
2216
- );
2217
- }
2218
- const cancelled = deps.relayCoordinator.cancel(
2219
- req.channelRequestId,
2220
- req.reason,
2221
- callerRuntimeId
2222
- );
2223
- const payload = { cancelled };
2224
- return ok(envelope, ts, payload);
2225
- }
2226
- case "relay.question.request": {
2227
- if (!deps.relayCoordinator)
2228
- return error(
2229
- envelope,
2230
- ts,
2231
- "unsupported",
2232
- "relay coordinator not configured"
2233
- );
2234
- const req = envelope.payload;
2235
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2236
- if (deps.registry && callerRuntimeId === void 0) {
2237
- return error(
2238
- envelope,
2239
- ts,
2240
- "not_registered",
2241
- "relay.question.request requires a registered runtime connection"
2242
- );
2243
- }
2244
- const broadcast = deps.relayCoordinator.requestQuestion({
2245
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2246
- title: req.title,
2247
- questions: req.questions,
2248
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2249
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2250
- });
2251
- const result = await broadcast.result;
2252
- const payload = {
2253
- channelRequestId: broadcast.channelRequestId,
2254
- result
2255
- };
2256
- return ok(envelope, Date.now(), payload);
2257
- }
2258
- case "relay.question.cancel": {
2259
- if (!deps.relayCoordinator)
2304
+ const spec = HANDLERS.get(envelope.kind);
2305
+ if (!spec) {
2306
+ return error(
2307
+ envelope,
2308
+ ts,
2309
+ "unknown_kind",
2310
+ `unknown kind: ${envelope.kind}`
2311
+ );
2312
+ }
2313
+ if (spec.requires) {
2314
+ for (const name of spec.requires) {
2315
+ if (deps[name] === void 0) {
2260
2316
  return error(
2261
2317
  envelope,
2262
2318
  ts,
2263
2319
  "unsupported",
2264
- "relay coordinator not configured"
2265
- );
2266
- const req = envelope.payload;
2267
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2268
- if (deps.registry && callerRuntimeId === void 0) {
2269
- return error(
2270
- envelope,
2271
- ts,
2272
- "not_registered",
2273
- "relay.question.cancel requires a registered runtime connection"
2320
+ spec.unsupportedMessage ?? `${spec.kind} not configured`
2274
2321
  );
2275
2322
  }
2276
- const cancelled = deps.relayCoordinator.cancel(
2277
- req.channelRequestId,
2278
- req.reason,
2279
- callerRuntimeId
2280
- );
2281
- const payload = { cancelled };
2282
- return ok(envelope, ts, payload);
2283
2323
  }
2284
- default:
2324
+ }
2325
+ let callerRuntimeId;
2326
+ if (spec.requireRegisteredRuntime) {
2327
+ callerRuntimeId = deps.pipeline?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2328
+ if (deps.pipeline && callerRuntimeId === void 0) {
2285
2329
  return error(
2286
2330
  envelope,
2287
2331
  ts,
2288
- "unknown_kind",
2289
- `unknown kind: ${envelope.kind}`
2332
+ "not_registered",
2333
+ `${spec.kind} requires a registered runtime connection`
2290
2334
  );
2335
+ }
2336
+ }
2337
+ try {
2338
+ const payload = await spec.handle(envelope, {
2339
+ deps,
2340
+ connection,
2341
+ callerRuntimeId,
2342
+ ts
2343
+ });
2344
+ return ok(envelope, Date.now(), payload);
2345
+ } catch (err) {
2346
+ if (err instanceof AlreadyRegisteredError || err instanceof NotRegisteredError) {
2347
+ return error(envelope, ts, err.code, err.message);
2348
+ }
2349
+ throw err;
2291
2350
  }
2292
2351
  };
2293
2352
  return handle;
2294
2353
  }
2295
- function runtimeStatusEntries(registry) {
2296
- const runtime = registry?.getCurrent();
2297
- if (!runtime || !registry) return [];
2298
- const binding = registry.getBinding();
2354
+ function runtimeStatusEntries(pipeline) {
2355
+ const runtime = pipeline?.getCurrentRuntime();
2356
+ if (!runtime || !pipeline) return [];
2357
+ const binding = pipeline.getBinding();
2299
2358
  return [
2300
2359
  {
2301
2360
  runtimeId: runtime.runtimeId,
@@ -2313,7 +2372,7 @@ function runtimeStatusEntries(registry) {
2313
2372
  epoch: binding.epoch,
2314
2373
  ...maybeLastRebindAt(binding.lastRebindAt)
2315
2374
  } : { state: "none" },
2316
- pendingDispatchCount: registry.pendingDispatchCount()
2375
+ pendingDispatchCount: pipeline.pendingDispatchCount()
2317
2376
  }
2318
2377
  ];
2319
2378
  }
@@ -2436,256 +2495,8 @@ function handleConnection(connection, expectedToken, startedAt, handler, logErro
2436
2495
  });
2437
2496
  }
2438
2497
 
2439
- // src/gateway/router/sessionKey.ts
2440
- function deriveSessionKey(loc) {
2441
- const c = loc.channelId;
2442
- const a = loc.accountId;
2443
- if (loc.peer?.id) {
2444
- const peer = loc.peer.id;
2445
- if (loc.thread?.id) {
2446
- return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2447
- }
2448
- return `peer:${c}:${a}:${peer}`;
2449
- }
2450
- if (loc.room?.id) {
2451
- const room = loc.room.id;
2452
- if (loc.thread?.id) {
2453
- return `room:${c}:${a}:${room}:${loc.thread.id}`;
2454
- }
2455
- return `room:${c}:${a}:${room}`;
2456
- }
2457
- return `default:${c}:${a}`;
2458
- }
2459
-
2460
- // src/gateway/dispatcher.ts
2461
- var Dispatcher = class {
2462
- registry;
2463
- pushDispatch;
2464
- sendOutbound;
2465
- resolveAgent;
2466
- inboundQueue;
2467
- canDispatch;
2468
- log;
2469
- constructor(opts) {
2470
- this.registry = opts.registry;
2471
- this.pushDispatch = opts.pushDispatch;
2472
- this.sendOutbound = opts.sendOutbound;
2473
- this.resolveAgent = opts.resolveAgent ?? ((input) => input.defaultAgentId);
2474
- this.inboundQueue = opts.inboundQueue;
2475
- this.canDispatch = opts.canDispatch ?? (() => true);
2476
- this.log = opts.log;
2477
- }
2478
- handleInbound(inbound) {
2479
- const current = this.registry.getCurrent();
2480
- if (!current || !this.canDispatch()) {
2481
- if (!this.inboundQueue) {
2482
- this.log?.(
2483
- "debug",
2484
- `no runtime registered; dropping inbound ${inbound.idempotencyKey}`
2485
- );
2486
- return { kind: "dropped", reason: "no_runtime" };
2487
- }
2488
- const result = this.inboundQueue.enqueue(inbound);
2489
- if (result.kind === "queued") {
2490
- this.log?.(
2491
- "info",
2492
- `no runtime registered; parked inbound ${inbound.idempotencyKey} as queue#${result.id}`
2493
- );
2494
- return { kind: "queued", queueId: result.id };
2495
- }
2496
- if (result.kind === "duplicate") {
2497
- this.log?.(
2498
- "debug",
2499
- `inbound ${inbound.idempotencyKey} already parked; ignoring duplicate`
2500
- );
2501
- return { kind: "dropped", reason: "no_runtime" };
2502
- }
2503
- this.log?.(
2504
- "warn",
2505
- `inbound queue full (>=${this.inboundQueue.size()}); dropping ${inbound.idempotencyKey}`
2506
- );
2507
- return { kind: "dropped", reason: "queue_full" };
2508
- }
2509
- const sessionKey = deriveSessionKey(inbound.location);
2510
- const agentId = this.resolveAgent({
2511
- sessionKey,
2512
- channelId: inbound.location.channelId,
2513
- defaultAgentId: current.defaultAgentId
2514
- });
2515
- if (!agentId) {
2516
- this.log?.(
2517
- "warn",
2518
- `agent resolution returned empty for sessionKey=${sessionKey}`
2519
- );
2520
- return { kind: "dropped", reason: "no_default_agent" };
2521
- }
2522
- const entry = this.registry.beginDispatch({
2523
- sessionKey,
2524
- agentId,
2525
- location: inbound.location
2526
- });
2527
- this.pushDispatch({
2528
- dispatchId: entry.dispatchId,
2529
- sessionKey,
2530
- agentId,
2531
- inbound
2532
- });
2533
- return {
2534
- kind: "dispatched",
2535
- dispatchId: entry.dispatchId,
2536
- sessionKey
2537
- };
2538
- }
2539
- /**
2540
- * Drain parked inbound messages and dispatch them in FIFO order. Called by
2541
- * the session.register handler after a runtime attaches. Safe to call when
2542
- * no queue is configured (no-op).
2543
- */
2544
- drainPending() {
2545
- if (!this.inboundQueue) return { dispatched: 0, dropped: 0 };
2546
- const current = this.registry.getCurrent();
2547
- if (!current || !this.canDispatch()) return { dispatched: 0, dropped: 0 };
2548
- const parked = this.inboundQueue.drain();
2549
- let dispatched = 0;
2550
- let dropped = 0;
2551
- for (const { inbound } of parked) {
2552
- const sessionKey = deriveSessionKey(inbound.location);
2553
- const agentId = this.resolveAgent({
2554
- sessionKey,
2555
- channelId: inbound.location.channelId,
2556
- defaultAgentId: current.defaultAgentId
2557
- });
2558
- if (!agentId) {
2559
- dropped += 1;
2560
- this.log?.(
2561
- "warn",
2562
- `drainPending: no agent for ${sessionKey}; dropping ${inbound.idempotencyKey}`
2563
- );
2564
- continue;
2565
- }
2566
- const entry = this.registry.beginDispatch({
2567
- sessionKey,
2568
- agentId,
2569
- location: inbound.location
2570
- });
2571
- this.pushDispatch({
2572
- dispatchId: entry.dispatchId,
2573
- sessionKey,
2574
- agentId,
2575
- inbound
2576
- });
2577
- dispatched += 1;
2578
- }
2579
- if (dispatched > 0 || dropped > 0) {
2580
- this.log?.(
2581
- "info",
2582
- `drainPending: dispatched=${dispatched} dropped=${dropped}`
2583
- );
2584
- }
2585
- return { dispatched, dropped };
2586
- }
2587
- async handleTurnComplete(payload) {
2588
- const current = this.registry.getCurrent();
2589
- writeGatewayTrace(
2590
- `dispatcher turn.complete received runtimeId=${payload.runtimeId} dispatchId=${payload.dispatchId} channel=${payload.location.channelId} account=${payload.location.accountId} thread=${payload.location.thread?.id ?? ""} textLength=${payload.text.length}`
2591
- );
2592
- if (!current || current.runtimeId !== payload.runtimeId) {
2593
- throw new Error("runtime mismatch on session.turn.complete");
2594
- }
2595
- let entry;
2596
- try {
2597
- entry = this.registry.completeDispatch(payload.dispatchId);
2598
- } catch (err) {
2599
- if (err instanceof UnknownDispatchError) {
2600
- writeGatewayTrace(
2601
- `dispatcher turn.complete unknown dispatchId=${payload.dispatchId}`
2602
- );
2603
- return { delivered: false };
2604
- }
2605
- throw err;
2606
- }
2607
- writeGatewayTrace(
2608
- `dispatcher sendOutbound channel=${entry.location.channelId} dispatchId=${payload.dispatchId} parkedAccount=${entry.location.accountId} parkedThread=${entry.location.thread?.id ?? ""}`
2609
- );
2610
- const result = await this.sendOutbound(entry.location.channelId, {
2611
- location: payload.location,
2612
- text: payload.text,
2613
- idempotencyKey: payload.idempotencyKey
2614
- });
2615
- writeGatewayTrace(
2616
- `dispatcher sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2617
- );
2618
- return {
2619
- delivered: true,
2620
- providerMessageId: result.providerMessageId
2621
- };
2622
- }
2623
- };
2624
-
2625
- // src/gateway/lock.ts
2626
- import fs from "fs";
2627
- import path from "path";
2628
- var GatewayAlreadyRunningError = class extends Error {
2629
- otherPid;
2630
- constructor(otherPid) {
2631
- super(`gateway already running (pid=${otherPid})`);
2632
- this.name = "GatewayAlreadyRunningError";
2633
- this.otherPid = otherPid;
2634
- }
2635
- };
2636
- function isProcessAlive(pid) {
2637
- try {
2638
- process.kill(pid, 0);
2639
- return true;
2640
- } catch (err) {
2641
- const code = err.code;
2642
- return code === "EPERM";
2643
- }
2644
- }
2645
- function readPidFile(p) {
2646
- try {
2647
- const text = fs.readFileSync(p, "utf-8").trim();
2648
- const pid = Number.parseInt(text, 10);
2649
- return Number.isInteger(pid) && pid > 0 ? pid : null;
2650
- } catch {
2651
- return null;
2652
- }
2653
- }
2654
- function acquireLock(lockPath) {
2655
- fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 448 });
2656
- for (let attempt = 0; attempt < 2; attempt++) {
2657
- try {
2658
- const fd = fs.openSync(lockPath, "wx", 384);
2659
- fs.writeSync(fd, String(process.pid) + "\n");
2660
- fs.closeSync(fd);
2661
- return {
2662
- path: lockPath,
2663
- pid: process.pid,
2664
- release: () => {
2665
- try {
2666
- const pidNow = readPidFile(lockPath);
2667
- if (pidNow === process.pid) {
2668
- fs.unlinkSync(lockPath);
2669
- }
2670
- } catch {
2671
- }
2672
- }
2673
- };
2674
- } catch (err) {
2675
- const code = err.code;
2676
- if (code !== "EEXIST") throw err;
2677
- const otherPid = readPidFile(lockPath);
2678
- if (otherPid !== null && isProcessAlive(otherPid)) {
2679
- throw new GatewayAlreadyRunningError(otherPid);
2680
- }
2681
- try {
2682
- fs.unlinkSync(lockPath);
2683
- } catch {
2684
- }
2685
- }
2686
- }
2687
- throw new Error(`failed to acquire gateway lock at ${lockPath}`);
2688
- }
2498
+ // src/gateway/dispatchPipeline.ts
2499
+ import crypto from "crypto";
2689
2500
 
2690
2501
  // src/gateway/outboundDispatcher.ts
2691
2502
  var DEFAULT_BACKOFF = [
@@ -2784,43 +2595,504 @@ var OutboundDispatcher = class {
2784
2595
  } finally {
2785
2596
  this.draining = false;
2786
2597
  }
2787
- return { retried, succeeded, dropped };
2788
- }
2789
- async attempt(row) {
2790
- try {
2791
- await this.send(row.channelId, row.message);
2792
- this.outbox.delete(row.id);
2598
+ return { retried, succeeded, dropped };
2599
+ }
2600
+ async attempt(row) {
2601
+ try {
2602
+ await this.send(row.channelId, row.message);
2603
+ this.outbox.delete(row.id);
2604
+ this.log?.(
2605
+ "info",
2606
+ `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
2607
+ );
2608
+ return "succeeded";
2609
+ } catch (err) {
2610
+ const error2 = err instanceof Error ? err.message : String(err);
2611
+ const nextAttempt = row.attempt + 1;
2612
+ if (nextAttempt >= this.maxAttempts) {
2613
+ this.outbox.delete(row.id);
2614
+ this.log?.(
2615
+ "error",
2616
+ `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2617
+ );
2618
+ return "dropped";
2619
+ }
2620
+ const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2621
+ this.outbox.recordFailure({
2622
+ id: row.id,
2623
+ nextAttemptAt,
2624
+ lastError: error2
2625
+ });
2626
+ return "requeued";
2627
+ }
2628
+ }
2629
+ backoffFor(attempt) {
2630
+ if (this.backoff.length === 0) return 1e3;
2631
+ const idx = Math.min(attempt, this.backoff.length - 1);
2632
+ return this.backoff[idx];
2633
+ }
2634
+ };
2635
+
2636
+ // src/gateway/router/sessionKey.ts
2637
+ function deriveSessionKey(loc) {
2638
+ const c = loc.channelId;
2639
+ const a = loc.accountId;
2640
+ if (loc.peer?.id) {
2641
+ const peer = loc.peer.id;
2642
+ if (loc.thread?.id) {
2643
+ return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2644
+ }
2645
+ return `peer:${c}:${a}:${peer}`;
2646
+ }
2647
+ if (loc.room?.id) {
2648
+ const room = loc.room.id;
2649
+ if (loc.thread?.id) {
2650
+ return `room:${c}:${a}:${room}:${loc.thread.id}`;
2651
+ }
2652
+ return `room:${c}:${a}:${room}`;
2653
+ }
2654
+ return `default:${c}:${a}`;
2655
+ }
2656
+
2657
+ // src/gateway/sessionRegistry.ts
2658
+ import { randomUUID } from "crypto";
2659
+ var UnknownDispatchError = class extends Error {
2660
+ code = "unknown_dispatch";
2661
+ constructor(id) {
2662
+ super(`unknown dispatchId: ${id}`);
2663
+ this.name = "UnknownDispatchError";
2664
+ }
2665
+ };
2666
+ var SessionRegistry = class {
2667
+ dispatches = /* @__PURE__ */ new Map();
2668
+ idFactory;
2669
+ now;
2670
+ constructor(opts = {}) {
2671
+ this.idFactory = opts.idFactory ?? randomUUID;
2672
+ this.now = opts.now ?? Date.now;
2673
+ }
2674
+ beginDispatch(input) {
2675
+ const dispatchId = this.idFactory();
2676
+ const entry = {
2677
+ dispatchId,
2678
+ sessionKey: input.sessionKey,
2679
+ agentId: input.agentId,
2680
+ location: input.location,
2681
+ createdAt: this.now()
2682
+ };
2683
+ this.dispatches.set(dispatchId, entry);
2684
+ return entry;
2685
+ }
2686
+ completeDispatch(dispatchId) {
2687
+ const entry = this.dispatches.get(dispatchId);
2688
+ if (!entry) {
2689
+ throw new UnknownDispatchError(dispatchId);
2690
+ }
2691
+ this.dispatches.delete(dispatchId);
2692
+ return entry;
2693
+ }
2694
+ pendingDispatchCount() {
2695
+ return this.dispatches.size;
2696
+ }
2697
+ clearDispatches() {
2698
+ this.dispatches.clear();
2699
+ }
2700
+ };
2701
+
2702
+ // src/gateway/state/inboundQueue.ts
2703
+ var DEFAULT_MAX_ENTRIES = 1e3;
2704
+ var InboundQueue = class {
2705
+ db;
2706
+ maxEntries;
2707
+ constructor(db, opts = {}) {
2708
+ this.db = db;
2709
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
2710
+ }
2711
+ size() {
2712
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
2713
+ return row.n;
2714
+ }
2715
+ enqueue(inbound) {
2716
+ if (this.size() >= this.maxEntries) {
2717
+ return { kind: "rejected", reason: "queue_full" };
2718
+ }
2719
+ const stmt = this.db.prepare(
2720
+ `INSERT INTO inbound_queue
2721
+ (channel_id, account_id, idempotency_key, payload_json, created_at)
2722
+ VALUES (?, ?, ?, ?, ?)
2723
+ ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
2724
+ );
2725
+ const result = stmt.run(
2726
+ inbound.location.channelId,
2727
+ inbound.location.accountId,
2728
+ inbound.idempotencyKey,
2729
+ JSON.stringify(inbound),
2730
+ Date.now()
2731
+ );
2732
+ if (result.changes === 0) {
2733
+ return { kind: "duplicate" };
2734
+ }
2735
+ return { kind: "queued", id: Number(result.lastInsertRowid) };
2736
+ }
2737
+ /** Atomically read and remove all parked entries in FIFO order. */
2738
+ drain() {
2739
+ return this.db.transaction(() => {
2740
+ const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
2741
+ if (rows.length > 0) {
2742
+ this.db.prepare("DELETE FROM inbound_queue").run();
2743
+ }
2744
+ return rows.map((r) => ({
2745
+ id: r.id,
2746
+ inbound: JSON.parse(r.payload_json)
2747
+ }));
2748
+ })();
2749
+ }
2750
+ };
2751
+
2752
+ // src/gateway/state/outbox.ts
2753
+ var Outbox = class {
2754
+ db;
2755
+ constructor(db) {
2756
+ this.db = db;
2757
+ }
2758
+ size() {
2759
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
2760
+ return row.n;
2761
+ }
2762
+ enqueue(input) {
2763
+ const result = this.db.prepare(
2764
+ `INSERT INTO channel_outbox
2765
+ (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
2766
+ VALUES (?, ?, 0, ?, ?, ?)`
2767
+ ).run(
2768
+ input.channelId,
2769
+ JSON.stringify(input.message),
2770
+ input.nextAttemptAt,
2771
+ input.lastError ?? null,
2772
+ Date.now()
2773
+ );
2774
+ return Number(result.lastInsertRowid);
2775
+ }
2776
+ /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
2777
+ peekDue(now, limit) {
2778
+ const rows = this.db.prepare(
2779
+ `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
2780
+ FROM channel_outbox
2781
+ WHERE next_attempt_at <= ?
2782
+ ORDER BY next_attempt_at ASC, id ASC
2783
+ LIMIT ?`
2784
+ ).all(now, limit);
2785
+ return rows.map((r) => ({
2786
+ id: r.id,
2787
+ channelId: r.channel_id,
2788
+ message: JSON.parse(r.payload_json),
2789
+ attempt: r.attempt,
2790
+ nextAttemptAt: r.next_attempt_at,
2791
+ lastError: r.last_error
2792
+ }));
2793
+ }
2794
+ delete(id) {
2795
+ this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
2796
+ }
2797
+ recordFailure(input) {
2798
+ this.db.prepare(
2799
+ `UPDATE channel_outbox
2800
+ SET attempt = attempt + 1,
2801
+ next_attempt_at = ?,
2802
+ last_error = ?
2803
+ WHERE id = ?`
2804
+ ).run(input.nextAttemptAt, input.lastError, input.id);
2805
+ }
2806
+ };
2807
+
2808
+ // src/gateway/dispatchPipeline.ts
2809
+ var DispatchPipeline = class {
2810
+ bindingStore;
2811
+ registry;
2812
+ inboundQueue;
2813
+ outbox;
2814
+ outboundDispatcher;
2815
+ resolveAgent;
2816
+ log;
2817
+ now;
2818
+ idFactory;
2819
+ push = null;
2820
+ constructor(opts) {
2821
+ this.bindingStore = new RuntimeBindingStore({
2822
+ gracePeriodMs: opts.gracePeriodMs,
2823
+ observers: opts.observers,
2824
+ now: opts.now
2825
+ });
2826
+ this.registry = new SessionRegistry({
2827
+ now: opts.now ?? Date.now,
2828
+ ...opts.idFactory ? { idFactory: opts.idFactory } : {}
2829
+ });
2830
+ this.inboundQueue = new InboundQueue(opts.stateDb, opts.inboundQueue ?? {});
2831
+ this.outbox = new Outbox(opts.stateDb);
2832
+ this.outboundDispatcher = new OutboundDispatcher({
2833
+ outbox: this.outbox,
2834
+ send: opts.send,
2835
+ ...opts.outbox?.backoffSchedule ? { backoffSchedule: opts.outbox.backoffSchedule } : {},
2836
+ ...opts.outbox?.maxAttempts !== void 0 ? { maxAttempts: opts.outbox.maxAttempts } : {},
2837
+ ...opts.outbox?.tickIntervalMs !== void 0 ? { tickIntervalMs: opts.outbox.tickIntervalMs } : {},
2838
+ ...opts.outbox?.drainBatchSize !== void 0 ? { drainBatchSize: opts.outbox.drainBatchSize } : {},
2839
+ ...opts.now ? { now: opts.now } : {},
2840
+ ...opts.log ? { log: opts.log } : {}
2841
+ });
2842
+ this.resolveAgent = opts.resolveAgent ?? ((input) => input.defaultAgentId);
2843
+ this.log = opts.log;
2844
+ this.now = opts.now ?? Date.now;
2845
+ this.idFactory = opts.idFactory ?? crypto.randomUUID;
2846
+ }
2847
+ // ── lifecycle ────────────────────────────────────────────
2848
+ start() {
2849
+ this.outboundDispatcher.start();
2850
+ }
2851
+ async stop() {
2852
+ this.outboundDispatcher.stop();
2853
+ this.bindingStore.stop();
2854
+ }
2855
+ // ── inbound (channel side) ───────────────────────────────
2856
+ handleInbound(inbound) {
2857
+ const current = this.bindingStore.getCurrent();
2858
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId)) {
2859
+ const result = this.inboundQueue.enqueue(inbound);
2860
+ if (result.kind === "queued") {
2861
+ this.log?.(
2862
+ "info",
2863
+ `no runtime registered; parked inbound ${inbound.idempotencyKey} as queue#${result.id}`
2864
+ );
2865
+ return { kind: "queued", queueId: result.id };
2866
+ }
2867
+ if (result.kind === "duplicate") {
2868
+ this.log?.(
2869
+ "debug",
2870
+ `inbound ${inbound.idempotencyKey} already parked; ignoring duplicate`
2871
+ );
2872
+ return { kind: "dropped", reason: "no_runtime" };
2873
+ }
2874
+ this.log?.(
2875
+ "warn",
2876
+ `inbound queue full (>=${this.inboundQueue.size()}); dropping ${inbound.idempotencyKey}`
2877
+ );
2878
+ return { kind: "dropped", reason: "queue_full" };
2879
+ }
2880
+ return this.dispatchInboundToRuntime(inbound, current);
2881
+ }
2882
+ dispatchInboundToRuntime(inbound, current) {
2883
+ const sessionKey = deriveSessionKey(inbound.location);
2884
+ const agentId = this.resolveAgent({
2885
+ sessionKey,
2886
+ channelId: inbound.location.channelId,
2887
+ defaultAgentId: current.defaultAgentId
2888
+ });
2889
+ if (!agentId) {
2890
+ this.log?.(
2891
+ "warn",
2892
+ `agent resolution returned empty for sessionKey=${sessionKey}`
2893
+ );
2894
+ return { kind: "dropped", reason: "no_default_agent" };
2895
+ }
2896
+ const entry = this.registry.beginDispatch({
2897
+ sessionKey,
2898
+ agentId,
2899
+ location: inbound.location
2900
+ });
2901
+ this.pushDispatch({
2902
+ dispatchId: entry.dispatchId,
2903
+ sessionKey,
2904
+ agentId,
2905
+ inbound
2906
+ });
2907
+ return { kind: "dispatched", dispatchId: entry.dispatchId, sessionKey };
2908
+ }
2909
+ pushDispatch(payload) {
2910
+ if (!this.push) return;
2911
+ this.push.push({
2912
+ push_id: this.idFactory(),
2913
+ ts: this.now(),
2914
+ kind: "session.dispatch.turn",
2915
+ payload
2916
+ });
2917
+ }
2918
+ // ── runtime side ─────────────────────────────────────────
2919
+ registerRuntime(input) {
2920
+ const result = this.bindingStore.bind({
2921
+ runtimeId: input.runtimeId,
2922
+ defaultAgentId: input.defaultAgentId,
2923
+ pid: input.pid,
2924
+ connectionId: input.connectionId
2925
+ });
2926
+ this.push = { connectionId: input.connectionId, push: input.push };
2927
+ writeGatewayTrace(
2928
+ `pipeline registered runtime runtimeId=${input.runtimeId} connectionId=${input.connectionId}`
2929
+ );
2930
+ this.drainPending();
2931
+ return { registeredAt: result.registeredAt };
2932
+ }
2933
+ unregisterRuntime(runtimeId) {
2934
+ this.bindingStore.unbind(runtimeId);
2935
+ this.registry.clearDispatches();
2936
+ this.push = null;
2937
+ writeGatewayTrace(`pipeline unregistered runtime runtimeId=${runtimeId}`);
2938
+ }
2939
+ notifyConnectionClosed(connectionId) {
2940
+ const runtimeId = this.bindingStore.notifyConnectionClosed(connectionId);
2941
+ if (runtimeId === null) return;
2942
+ writeGatewayTrace(
2943
+ `pipeline runtime connection closed runtimeId=${runtimeId} connectionId=${connectionId}`
2944
+ );
2945
+ this.push = null;
2946
+ }
2947
+ async handleTurnComplete(payload) {
2948
+ const current = this.bindingStore.getCurrent();
2949
+ writeGatewayTrace(
2950
+ `pipeline turn.complete received runtimeId=${payload.runtimeId} dispatchId=${payload.dispatchId} channel=${payload.location.channelId} account=${payload.location.accountId} thread=${payload.location.thread?.id ?? ""} textLength=${payload.text.length}`
2951
+ );
2952
+ if (!current || current.runtimeId !== payload.runtimeId) {
2953
+ throw new Error("runtime mismatch on session.turn.complete");
2954
+ }
2955
+ let entry;
2956
+ try {
2957
+ entry = this.registry.completeDispatch(payload.dispatchId);
2958
+ } catch (err) {
2959
+ if (err instanceof UnknownDispatchError) {
2960
+ writeGatewayTrace(
2961
+ `pipeline turn.complete unknown dispatchId=${payload.dispatchId}`
2962
+ );
2963
+ return { delivered: false };
2964
+ }
2965
+ throw err;
2966
+ }
2967
+ const result = await this.sendOutbound(entry.location, payload);
2968
+ writeGatewayTrace(
2969
+ `pipeline sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2970
+ );
2971
+ return { delivered: true, providerMessageId: result.providerMessageId };
2972
+ }
2973
+ async sendOutbound(_parkedLocation, payload) {
2974
+ const out = {
2975
+ location: payload.location,
2976
+ text: payload.text,
2977
+ idempotencyKey: payload.idempotencyKey
2978
+ };
2979
+ const result = await this.outboundDispatcher.dispatch(
2980
+ payload.location.channelId,
2981
+ out
2982
+ );
2983
+ if (result.kind === "sent") return result.result;
2984
+ return {
2985
+ providerMessageId: `outbox:${result.outboxId}`,
2986
+ deliveredAt: this.now()
2987
+ };
2988
+ }
2989
+ drainPending() {
2990
+ const current = this.bindingStore.getCurrent();
2991
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId))
2992
+ return;
2993
+ const parked = this.inboundQueue.drain();
2994
+ let dispatched = 0;
2995
+ let dropped = 0;
2996
+ for (const { inbound } of parked) {
2997
+ const result = this.dispatchInboundToRuntime(inbound, current);
2998
+ if (result.kind === "dispatched") dispatched += 1;
2999
+ else dropped += 1;
3000
+ }
3001
+ if (dispatched > 0 || dropped > 0) {
2793
3002
  this.log?.(
2794
3003
  "info",
2795
- `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
3004
+ `drainPending: dispatched=${dispatched} dropped=${dropped}`
2796
3005
  );
2797
- return "succeeded";
2798
- } catch (err) {
2799
- const error2 = err instanceof Error ? err.message : String(err);
2800
- const nextAttempt = row.attempt + 1;
2801
- if (nextAttempt >= this.maxAttempts) {
2802
- this.outbox.delete(row.id);
2803
- this.log?.(
2804
- "error",
2805
- `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2806
- );
2807
- return "dropped";
2808
- }
2809
- const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2810
- this.outbox.recordFailure({
2811
- id: row.id,
2812
- nextAttemptAt,
2813
- lastError: error2
2814
- });
2815
- return "requeued";
2816
3006
  }
2817
3007
  }
2818
- backoffFor(attempt) {
2819
- if (this.backoff.length === 0) return 1e3;
2820
- const idx = Math.min(attempt, this.backoff.length - 1);
2821
- return this.backoff[idx];
3008
+ // ── reads ────────────────────────────────────────────────
3009
+ getCurrentRuntime() {
3010
+ return this.bindingStore.getCurrent();
3011
+ }
3012
+ getBinding() {
3013
+ return this.bindingStore.getBinding();
3014
+ }
3015
+ hasActiveBinding(runtimeId) {
3016
+ return this.bindingStore.hasActiveBinding(runtimeId);
3017
+ }
3018
+ getRuntimeIdByConnection(connectionId) {
3019
+ return this.bindingStore.getRuntimeIdByConnection(connectionId);
3020
+ }
3021
+ pendingDispatchCount() {
3022
+ return this.registry.pendingDispatchCount();
3023
+ }
3024
+ pendingInboundCount() {
3025
+ return this.inboundQueue.size();
3026
+ }
3027
+ pendingOutboxCount() {
3028
+ return this.outbox.size();
3029
+ }
3030
+ };
3031
+
3032
+ // src/gateway/lock.ts
3033
+ import fs from "fs";
3034
+ import path from "path";
3035
+ var GatewayAlreadyRunningError = class extends Error {
3036
+ otherPid;
3037
+ constructor(otherPid) {
3038
+ super(`gateway already running (pid=${otherPid})`);
3039
+ this.name = "GatewayAlreadyRunningError";
3040
+ this.otherPid = otherPid;
2822
3041
  }
2823
3042
  };
3043
+ function isProcessAlive(pid) {
3044
+ try {
3045
+ process.kill(pid, 0);
3046
+ return true;
3047
+ } catch (err) {
3048
+ const code = err.code;
3049
+ return code === "EPERM";
3050
+ }
3051
+ }
3052
+ function readPidFile(p) {
3053
+ try {
3054
+ const text = fs.readFileSync(p, "utf-8").trim();
3055
+ const pid = Number.parseInt(text, 10);
3056
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
3057
+ } catch {
3058
+ return null;
3059
+ }
3060
+ }
3061
+ function acquireLock(lockPath) {
3062
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 448 });
3063
+ for (let attempt = 0; attempt < 2; attempt++) {
3064
+ try {
3065
+ const fd = fs.openSync(lockPath, "wx", 384);
3066
+ fs.writeSync(fd, String(process.pid) + "\n");
3067
+ fs.closeSync(fd);
3068
+ return {
3069
+ path: lockPath,
3070
+ pid: process.pid,
3071
+ release: () => {
3072
+ try {
3073
+ const pidNow = readPidFile(lockPath);
3074
+ if (pidNow === process.pid) {
3075
+ fs.unlinkSync(lockPath);
3076
+ }
3077
+ } catch {
3078
+ }
3079
+ }
3080
+ };
3081
+ } catch (err) {
3082
+ const code = err.code;
3083
+ if (code !== "EEXIST") throw err;
3084
+ const otherPid = readPidFile(lockPath);
3085
+ if (otherPid !== null && isProcessAlive(otherPid)) {
3086
+ throw new GatewayAlreadyRunningError(otherPid);
3087
+ }
3088
+ try {
3089
+ fs.unlinkSync(lockPath);
3090
+ } catch {
3091
+ }
3092
+ }
3093
+ }
3094
+ throw new Error(`failed to acquire gateway lock at ${lockPath}`);
3095
+ }
2824
3096
 
2825
3097
  // src/gateway/relay/coordinator.ts
2826
3098
  var DEFAULT_RELAY_TTL_MS = 5 * 6e4;
@@ -2874,10 +3146,7 @@ var RelayCoordinator = class {
2874
3146
  resolveFn = resolve;
2875
3147
  });
2876
3148
  const timer = setTimeout(() => {
2877
- this.settlePermission(channelRequestId, {
2878
- kind: "cancelled",
2879
- reason: "timeout"
2880
- });
3149
+ this.settle(channelRequestId, { kind: "cancelled", reason: "timeout" });
2881
3150
  }, ttlMs);
2882
3151
  if (typeof timer.unref === "function") timer.unref();
2883
3152
  const entry = {
@@ -2902,10 +3171,7 @@ var RelayCoordinator = class {
2902
3171
  const ctrl = controllers[idx];
2903
3172
  Promise.resolve().then(() => adapter.requestPermissionVerdict(fullReq, ctrl.signal)).then((res) => {
2904
3173
  if (res.kind === "verdict") {
2905
- this.settlePermission(channelRequestId, {
2906
- ...res,
2907
- channelId: adapter.id
2908
- });
3174
+ this.settle(channelRequestId, { ...res, channelId: adapter.id });
2909
3175
  }
2910
3176
  }).catch((err) => {
2911
3177
  this.log?.(
@@ -2954,10 +3220,7 @@ var RelayCoordinator = class {
2954
3220
  resolveFn = resolve;
2955
3221
  });
2956
3222
  const timer = setTimeout(() => {
2957
- this.settleQuestion(channelRequestId, {
2958
- kind: "cancelled",
2959
- reason: "timeout"
2960
- });
3223
+ this.settle(channelRequestId, { kind: "cancelled", reason: "timeout" });
2961
3224
  }, ttlMs);
2962
3225
  if (typeof timer.unref === "function") timer.unref();
2963
3226
  const entry = {
@@ -2981,10 +3244,7 @@ var RelayCoordinator = class {
2981
3244
  const ctrl = controllers[idx];
2982
3245
  Promise.resolve().then(() => adapter.requestQuestionAnswer(fullReq, ctrl.signal)).then((res) => {
2983
3246
  if (res.kind === "answer") {
2984
- this.settleQuestion(channelRequestId, {
2985
- ...res,
2986
- channelId: adapter.id
2987
- });
3247
+ this.settle(channelRequestId, { ...res, channelId: adapter.id });
2988
3248
  }
2989
3249
  }).catch((err) => {
2990
3250
  this.log?.(
@@ -3001,11 +3261,7 @@ var RelayCoordinator = class {
3001
3261
  if (expectedRuntimeId !== void 0 && entry.runtimeId !== void 0 && entry.runtimeId !== expectedRuntimeId) {
3002
3262
  return false;
3003
3263
  }
3004
- if (entry.kind === "permission") {
3005
- this.settlePermission(channelRequestId, { kind: "cancelled", reason });
3006
- } else {
3007
- this.settleQuestion(channelRequestId, { kind: "cancelled", reason });
3008
- }
3264
+ this.settle(channelRequestId, { kind: "cancelled", reason });
3009
3265
  return true;
3010
3266
  }
3011
3267
  pendingCount() {
@@ -3016,27 +3272,18 @@ var RelayCoordinator = class {
3016
3272
  this.cancel(id, reason);
3017
3273
  }
3018
3274
  }
3019
- settlePermission(channelRequestId, result) {
3020
- const entry = this.pending.get(channelRequestId);
3021
- if (!entry || entry.kind !== "permission" || entry.settled) return;
3022
- entry.settled = true;
3023
- this.pending.delete(channelRequestId);
3024
- clearTimeout(entry.timer);
3025
- for (const ctrl of entry.controllers) {
3026
- if (!ctrl.signal.aborted) ctrl.abort();
3027
- }
3028
- entry.resolve(result);
3029
- }
3030
- settleQuestion(channelRequestId, result) {
3275
+ settle(channelRequestId, result) {
3031
3276
  const entry = this.pending.get(channelRequestId);
3032
- if (!entry || entry.kind !== "question" || entry.settled) return;
3277
+ if (!entry || entry.settled) return;
3033
3278
  entry.settled = true;
3034
3279
  this.pending.delete(channelRequestId);
3035
3280
  clearTimeout(entry.timer);
3036
3281
  for (const ctrl of entry.controllers) {
3037
3282
  if (!ctrl.signal.aborted) ctrl.abort();
3038
3283
  }
3039
- entry.resolve(result);
3284
+ entry.resolve(
3285
+ result
3286
+ );
3040
3287
  }
3041
3288
  };
3042
3289
  function permissionFingerprint(req) {
@@ -3116,112 +3363,6 @@ function initGatewayStateSchema(db) {
3116
3363
  }
3117
3364
  }
3118
3365
 
3119
- // src/gateway/state/inboundQueue.ts
3120
- var DEFAULT_MAX_ENTRIES = 1e3;
3121
- var InboundQueue = class {
3122
- db;
3123
- maxEntries;
3124
- constructor(db, opts = {}) {
3125
- this.db = db;
3126
- this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
3127
- }
3128
- size() {
3129
- const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
3130
- return row.n;
3131
- }
3132
- enqueue(inbound) {
3133
- if (this.size() >= this.maxEntries) {
3134
- return { kind: "rejected", reason: "queue_full" };
3135
- }
3136
- const stmt = this.db.prepare(
3137
- `INSERT INTO inbound_queue
3138
- (channel_id, account_id, idempotency_key, payload_json, created_at)
3139
- VALUES (?, ?, ?, ?, ?)
3140
- ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
3141
- );
3142
- const result = stmt.run(
3143
- inbound.location.channelId,
3144
- inbound.location.accountId,
3145
- inbound.idempotencyKey,
3146
- JSON.stringify(inbound),
3147
- Date.now()
3148
- );
3149
- if (result.changes === 0) {
3150
- return { kind: "duplicate" };
3151
- }
3152
- return { kind: "queued", id: Number(result.lastInsertRowid) };
3153
- }
3154
- /** Atomically read and remove all parked entries in FIFO order. */
3155
- drain() {
3156
- return this.db.transaction(() => {
3157
- const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
3158
- if (rows.length > 0) {
3159
- this.db.prepare("DELETE FROM inbound_queue").run();
3160
- }
3161
- return rows.map((r) => ({
3162
- id: r.id,
3163
- inbound: JSON.parse(r.payload_json)
3164
- }));
3165
- })();
3166
- }
3167
- };
3168
-
3169
- // src/gateway/state/outbox.ts
3170
- var Outbox = class {
3171
- db;
3172
- constructor(db) {
3173
- this.db = db;
3174
- }
3175
- size() {
3176
- const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
3177
- return row.n;
3178
- }
3179
- enqueue(input) {
3180
- const result = this.db.prepare(
3181
- `INSERT INTO channel_outbox
3182
- (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
3183
- VALUES (?, ?, 0, ?, ?, ?)`
3184
- ).run(
3185
- input.channelId,
3186
- JSON.stringify(input.message),
3187
- input.nextAttemptAt,
3188
- input.lastError ?? null,
3189
- Date.now()
3190
- );
3191
- return Number(result.lastInsertRowid);
3192
- }
3193
- /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
3194
- peekDue(now, limit) {
3195
- const rows = this.db.prepare(
3196
- `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
3197
- FROM channel_outbox
3198
- WHERE next_attempt_at <= ?
3199
- ORDER BY next_attempt_at ASC, id ASC
3200
- LIMIT ?`
3201
- ).all(now, limit);
3202
- return rows.map((r) => ({
3203
- id: r.id,
3204
- channelId: r.channel_id,
3205
- message: JSON.parse(r.payload_json),
3206
- attempt: r.attempt,
3207
- nextAttemptAt: r.next_attempt_at,
3208
- lastError: r.last_error
3209
- }));
3210
- }
3211
- delete(id) {
3212
- this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
3213
- }
3214
- recordFailure(input) {
3215
- this.db.prepare(
3216
- `UPDATE channel_outbox
3217
- SET attempt = attempt + 1,
3218
- next_attempt_at = ?,
3219
- last_error = ?
3220
- WHERE id = ?`
3221
- ).run(input.nextAttemptAt, input.lastError, input.id);
3222
- }
3223
- };
3224
-
3225
3366
  // src/gateway/transport/tlsWs.ts
3226
3367
  import { WebSocketServer } from "ws";
3227
3368
  import { createServer as createHttpsServer } from "https";
@@ -3466,62 +3607,35 @@ async function startDaemon(opts) {
3466
3607
  loopback: listenSpec.kind === "uds" || isLoopbackHost(listenSpec.host)
3467
3608
  };
3468
3609
  const stateDb = openGatewayState(paths.statePath);
3469
- const inboundQueue = new InboundQueue(stateDb);
3470
- const outbox = new Outbox(stateDb);
3471
- const registry = new SessionRegistry();
3472
3610
  const channelManager = new ChannelManager();
3473
3611
  const relayCoordinator = new RelayCoordinator({
3474
3612
  adapters: () => channelManager.listAdapters()
3475
3613
  });
3476
- const runtimeConnections = /* @__PURE__ */ new Map();
3477
- const staleRuntimeTimers = /* @__PURE__ */ new Map();
3478
3614
  const connectionOpenedAt = /* @__PURE__ */ new Map();
3479
3615
  const disconnectGracePeriodMs = opts.disconnectGracePeriodMs ?? 0;
3480
3616
  let listenerStatus = null;
3481
- const pushDispatch = (payload) => {
3482
- const current = registry.getCurrent();
3483
- if (!current) return;
3484
- const ctx = runtimeConnections.get(current.runtimeId);
3485
- if (!ctx) return;
3486
- ctx.push({
3487
- push_id: crypto.randomUUID(),
3488
- ts: Date.now(),
3489
- kind: "session.dispatch.turn",
3490
- payload
3491
- });
3492
- };
3493
3617
  const log = (level, message) => {
3494
3618
  if (opts.silent) return;
3495
3619
  const stream = level === "error" || level === "warn" ? "stderr" : "stdout";
3496
3620
  process[stream].write(`athena-gateway: [${level}] ${message}
3497
3621
  `);
3498
3622
  };
3499
- const outboundDispatcher = new OutboundDispatcher({
3500
- outbox,
3623
+ const pipeline = new DispatchPipeline({
3624
+ stateDb,
3501
3625
  send: (channelId, msg) => channelManager.send(channelId, msg),
3502
- log
3503
- });
3504
- outboundDispatcher.start();
3505
- const dispatcher = new Dispatcher({
3506
- registry,
3507
- pushDispatch,
3508
- canDispatch: () => {
3509
- const current = registry.getCurrent();
3510
- return current ? registry.hasActiveBinding(current.runtimeId) : false;
3511
- },
3512
- sendOutbound: async (channelId, msg) => {
3513
- const result = await outboundDispatcher.dispatch(channelId, msg);
3514
- if (result.kind === "sent") return result.result;
3515
- return {
3516
- providerMessageId: `outbox:${result.outboxId}`,
3517
- deliveredAt: Date.now()
3518
- };
3519
- },
3520
- inboundQueue,
3521
- log
3626
+ gracePeriodMs: disconnectGracePeriodMs,
3627
+ log,
3628
+ observers: {
3629
+ onRuntimeRebind: ({ gapMs, epoch }) => trackGatewayRuntimeRebind({ gapMs, epoch }),
3630
+ onRuntimeExpired: ({ gapMs }) => trackGatewayRuntimeExpired({ gapMs }),
3631
+ // Single-runtime v1: blanket dispose is safe. Multi-runtime must
3632
+ // scope to the disconnecting runtime via disposeAllForRuntime.
3633
+ onRuntimeConnectionLost: () => relayCoordinator.disposeAll("connection_lost")
3634
+ }
3522
3635
  });
3636
+ pipeline.start();
3523
3637
  channelManager.setInboundSink((inbound) => {
3524
- dispatcher.handleInbound(inbound);
3638
+ pipeline.handleInbound(inbound);
3525
3639
  });
3526
3640
  const channelConfigHome = opts.env?.HOME;
3527
3641
  const reloadChannels = async () => {
@@ -3635,45 +3749,11 @@ async function startDaemon(opts) {
3635
3749
  }
3636
3750
  const handler = createDispatcher({
3637
3751
  startedAt,
3638
- registry,
3639
- dispatcher,
3752
+ pipeline,
3640
3753
  channelManager,
3641
3754
  relayCoordinator,
3642
3755
  getListener: () => listenerStatus ?? buildListenerStatus(listenSpec, null),
3643
- reloadChannels,
3644
- registerRuntimeConnection: (runtimeId, ctx) => {
3645
- const timer = staleRuntimeTimers.get(runtimeId);
3646
- if (timer) {
3647
- clearTimeout(timer);
3648
- staleRuntimeTimers.delete(runtimeId);
3649
- }
3650
- const previousBinding = registry.getBinding();
3651
- const wasStale = previousBinding?.state === "stale";
3652
- const staleSince = wasStale ? previousBinding.staleSince : null;
3653
- registry.bindConnection(runtimeId, ctx.connectionId);
3654
- runtimeConnections.set(runtimeId, ctx);
3655
- writeGatewayTrace(
3656
- `daemon registered runtime runtimeId=${runtimeId} connectionId=${ctx.connectionId}`
3657
- );
3658
- if (wasStale && staleSince !== null) {
3659
- const newBinding = registry.getBinding();
3660
- if (newBinding?.state === "active") {
3661
- trackGatewayRuntimeRebind({
3662
- gapMs: Date.now() - staleSince,
3663
- epoch: newBinding.epoch
3664
- });
3665
- }
3666
- }
3667
- },
3668
- unregisterRuntimeConnection: (runtimeId) => {
3669
- const timer = staleRuntimeTimers.get(runtimeId);
3670
- if (timer) {
3671
- clearTimeout(timer);
3672
- staleRuntimeTimers.delete(runtimeId);
3673
- }
3674
- runtimeConnections.delete(runtimeId);
3675
- writeGatewayTrace(`daemon unregistered runtime runtimeId=${runtimeId}`);
3676
- }
3756
+ reloadChannels
3677
3757
  });
3678
3758
  let server;
3679
3759
  let listener;
@@ -3707,39 +3787,7 @@ async function startDaemon(opts) {
3707
3787
  reason: "closed",
3708
3788
  durationMs
3709
3789
  });
3710
- const current = registry.getCurrent();
3711
- if (current && runtimeConnections.get(current.runtimeId)?.connectionId === ctx.connectionId) {
3712
- writeGatewayTrace(
3713
- `daemon runtime connection disconnected runtimeId=${current.runtimeId} connectionId=${ctx.connectionId}`
3714
- );
3715
- runtimeConnections.delete(current.runtimeId);
3716
- const staleAt = Date.now();
3717
- registry.markConnectionStale(ctx.connectionId);
3718
- if (disconnectGracePeriodMs <= 0) {
3719
- try {
3720
- registry.unregister(current.runtimeId);
3721
- } catch {
3722
- }
3723
- relayCoordinator.disposeAll("connection_lost");
3724
- return;
3725
- }
3726
- const runtimeId = current.runtimeId;
3727
- const timer = setTimeout(() => {
3728
- staleRuntimeTimers.delete(runtimeId);
3729
- const latest = registry.getCurrent();
3730
- if (latest?.runtimeId === runtimeId && !registry.hasActiveBinding(runtimeId)) {
3731
- try {
3732
- registry.unregister(runtimeId);
3733
- } catch {
3734
- }
3735
- relayCoordinator.disposeAll("connection_lost");
3736
- trackGatewayRuntimeExpired({
3737
- gapMs: Date.now() - staleAt
3738
- });
3739
- }
3740
- }, disconnectGracePeriodMs);
3741
- staleRuntimeTimers.set(runtimeId, timer);
3742
- }
3790
+ pipeline.notifyConnectionClosed(ctx.connectionId);
3743
3791
  }
3744
3792
  });
3745
3793
  if (listenSpec.kind === "tcp") {
@@ -3775,11 +3823,7 @@ async function startDaemon(opts) {
3775
3823
  if (stopping) return;
3776
3824
  stopping = true;
3777
3825
  try {
3778
- outboundDispatcher.stop();
3779
- for (const timer of staleRuntimeTimers.values()) {
3780
- clearTimeout(timer);
3781
- }
3782
- staleRuntimeTimers.clear();
3826
+ await pipeline.stop();
3783
3827
  relayCoordinator.disposeAll("auto_resolved");
3784
3828
  await channelManager.stop();
3785
3829
  await server.close();
@@ -3804,13 +3848,9 @@ async function startDaemon(opts) {
3804
3848
  startedAt,
3805
3849
  pid,
3806
3850
  paths,
3807
- registry,
3808
- dispatcher,
3851
+ pipeline,
3809
3852
  channelManager,
3810
3853
  relayCoordinator,
3811
- inboundQueue,
3812
- outbox,
3813
- outboundDispatcher,
3814
3854
  listener,
3815
3855
  stop
3816
3856
  };