agent-embassy 2.0.1 → 3.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/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +19 -34
- package/README.md +110 -228
- package/SECURITY.md +55 -89
- package/dist/src/errors.d.ts +10 -1
- package/dist/src/errors.js +3 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
- package/dist/src/gateway/claude-helper-protocol.js +12 -11
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
- package/dist/src/gateway/claude-helper-supervisor.js +10 -9
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
- package/dist/src/gateway/claude-helper.js +6 -6
- package/dist/src/gateway/claude-helper.js.map +1 -1
- package/dist/src/gateway/claude-peer.d.ts +0 -3
- package/dist/src/gateway/claude-peer.js +6 -18
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +17 -6
- package/dist/src/gateway/cli.js +912 -261
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
- package/dist/src/gateway/codex-socket-holder.js +76 -0
- package/dist/src/gateway/codex-socket-holder.js.map +1 -0
- package/dist/src/gateway/codex-stateless-transport.js +1 -1
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +1 -10
- package/dist/src/gateway/config.js +4 -10
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +45 -77
- package/dist/src/gateway/control.js +56 -141
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +29 -2
- package/dist/src/gateway/federation-nodes.js +177 -7
- package/dist/src/gateway/federation-nodes.js.map +1 -1
- package/dist/src/gateway/peer-client.d.ts +4 -3
- package/dist/src/gateway/peer-client.js +22 -13
- package/dist/src/gateway/peer-client.js.map +1 -1
- package/dist/src/gateway/peer-protocol.d.ts +8 -11
- package/dist/src/gateway/peer-protocol.js +5 -12
- package/dist/src/gateway/peer-protocol.js.map +1 -1
- package/dist/src/gateway/provenance-envelope.d.ts +0 -1
- package/dist/src/gateway/provenance-envelope.js +4 -19
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/providers.d.ts +7 -4
- package/dist/src/gateway/providers.js +16 -20
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +3 -12
- package/dist/src/gateway/server.js +31 -50
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/service-agent.d.ts +187 -0
- package/dist/src/gateway/service-agent.js +758 -0
- package/dist/src/gateway/service-agent.js.map +1 -0
- package/dist/src/gateway/service.d.ts +114 -31
- package/dist/src/gateway/service.js +461 -566
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/status-view.d.ts +167 -0
- package/dist/src/gateway/status-view.js +488 -0
- package/dist/src/gateway/status-view.js.map +1 -0
- package/dist/src/gateway/store.d.ts +103 -21
- package/dist/src/gateway/store.js +454 -529
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +48 -99
- package/dist/src/gateway/types.js +15 -52
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +173 -44
- package/docs/DELIVERY.md +11 -11
- package/docs/GATEWAY-ARCHITECTURE.md +277 -375
- package/package.json +4 -12
- package/skills/embassy-peer/SKILL.md +65 -90
- package/skills/embassy-peer/agents/openai.yaml +1 -1
- package/README.zh-CN.md +0 -275
- package/assets/live-dashboard/app.css +0 -1619
- package/assets/vendor/react/LICENSE +0 -21
- package/assets/vendor/react/react-dom.production.min.js +0 -267
- package/assets/vendor/react/react.production.min.js +0 -31
- package/dist/src/gateway/acp-client.d.ts +0 -110
- package/dist/src/gateway/acp-client.js +0 -407
- package/dist/src/gateway/acp-client.js.map +0 -1
- package/dist/src/gateway/acp-provider.d.ts +0 -66
- package/dist/src/gateway/acp-provider.js +0 -275
- package/dist/src/gateway/acp-provider.js.map +0 -1
- package/dist/src/gateway/cli-copy.d.ts +0 -8
- package/dist/src/gateway/cli-copy.en.d.ts +0 -22
- package/dist/src/gateway/cli-copy.en.js +0 -62
- package/dist/src/gateway/cli-copy.en.js.map +0 -1
- package/dist/src/gateway/cli-copy.js +0 -27
- package/dist/src/gateway/cli-copy.js.map +0 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
- package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
- package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/codex-doctor.d.ts +0 -36
- package/dist/src/gateway/codex-doctor.js +0 -127
- package/dist/src/gateway/codex-doctor.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.d.ts +0 -7
- package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.en.js +0 -505
- package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.js +0 -514
- package/dist/src/gateway/dashboard-copy.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/dashboard-model.d.ts +0 -343
- package/dist/src/gateway/dashboard-model.js +0 -1061
- package/dist/src/gateway/dashboard-model.js.map +0 -1
- package/dist/src/gateway/dashboard.d.ts +0 -20
- package/dist/src/gateway/dashboard.js +0 -874
- package/dist/src/gateway/dashboard.js.map +0 -1
- package/dist/src/gateway/deepseek-detect.d.ts +0 -14
- package/dist/src/gateway/deepseek-detect.js +0 -41
- package/dist/src/gateway/deepseek-detect.js.map +0 -1
- package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
- package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
- package/dist/src/gateway/live-dashboard-assets.js +0 -74
- package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
- package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
- package/dist/src/gateway/live-dashboard-command.js +0 -334
- package/dist/src/gateway/live-dashboard-command.js.map +0 -1
- package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
- package/dist/src/gateway/live-dashboard-http.js +0 -383
- package/dist/src/gateway/live-dashboard-http.js.map +0 -1
- package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
- package/dist/src/gateway/live-dashboard-protocol.js +0 -114
- package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
- package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
- package/dist/src/gateway/live-dashboard-server.js +0 -144
- package/dist/src/gateway/live-dashboard-server.js.map +0 -1
- package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
- package/dist/src/gateway/live-dashboard-stream.js +0 -234
- package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
- package/dist/src/gateway/live-dashboard.d.ts +0 -28
- package/dist/src/gateway/live-dashboard.js +0 -154
- package/dist/src/gateway/live-dashboard.js.map +0 -1
- package/dist/src/gateway/locale.d.ts +0 -4
- package/dist/src/gateway/locale.js +0 -10
- package/dist/src/gateway/locale.js.map +0 -1
- package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
- package/dist/src/gateway/progress-watch-machine.js +0 -70
- package/dist/src/gateway/progress-watch-machine.js.map +0 -1
- package/docs/CONFIGURATION.zh-CN.md +0 -97
- package/docs/DASHBOARD.md +0 -98
- package/docs/DASHBOARD.zh-CN.md +0 -49
- package/docs/DELIVERY.zh-CN.md +0 -55
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { BridgeError } from "../errors.js";
|
|
3
|
-
import { createGatewayConversationId, startGatewayControlServer, } from "./control.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { peerEdgeRef, peerRouteRef } from "./peer-protocol.js";
|
|
7
|
-
import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_DEFAULT_IDLE_MS, PROGRESS_WATCH_HARD_CAPACITY, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, recordProgressWatchActivity, } from "./progress-watch-machine.js";
|
|
3
|
+
import { createGatewayConversationId, startGatewayControlServer, GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS, } from "./control.js";
|
|
4
|
+
import { PeerConnectionLostError, spawnPeerClient } from "./peer-client.js";
|
|
5
|
+
import { peerRouteRef } from "./peer-protocol.js";
|
|
8
6
|
import { GatewayStore } from "./store.js";
|
|
9
|
-
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
|
|
7
|
+
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, directionId, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
|
|
10
8
|
const SAFE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
11
9
|
const PRIVATE_HANDLE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
12
10
|
const PUBLIC_ALIAS = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
|
|
13
11
|
const PEER_TOKEN = /^peer_[A-Za-z0-9_-]{32}$/;
|
|
14
12
|
const DISCOVERY_INTERVAL_MS = 30_000;
|
|
15
|
-
const
|
|
13
|
+
const MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS = 30_000;
|
|
16
14
|
const PEER_REFRESH_INTERVAL_MS = 30_000;
|
|
15
|
+
/** A failed timed retirement of an ephemeral peer is retried this much later… */
|
|
16
|
+
const EPHEMERAL_EXPIRY_RETRY_MS = 5_000;
|
|
17
|
+
/** …this many times in a row before the registration is left to the next restart. */
|
|
18
|
+
const EPHEMERAL_EXPIRY_MAX_ATTEMPTS = 3;
|
|
19
|
+
const PEER_FAILURE_CODES = ["PEER_DIAL_FAILED", "PEER_TUNNEL_UNAVAILABLE", "PEER_PROTOCOL_MISMATCH"];
|
|
17
20
|
const CLEAN_RETRY_DELAY_MS = 500;
|
|
18
21
|
const MAX_CONVERSATIONS = 1_024;
|
|
19
22
|
const MAX_PENDING_CLAUDE_REPLIES = MAX_CONVERSATIONS;
|
|
@@ -39,16 +42,7 @@ function aliasHost(alias) {
|
|
|
39
42
|
function safeCode(value, fallback) {
|
|
40
43
|
return value !== undefined && SAFE_CODE.test(value) ? value : fallback;
|
|
41
44
|
}
|
|
42
|
-
class ConsentOwnerError extends BridgeError {
|
|
43
|
-
ownerHost;
|
|
44
|
-
constructor(ownerHost) {
|
|
45
|
-
super("CONSENT_OWNER_HOST_REQUIRED", `Run pair or unpair on the edge owner host ${ownerHost}.`);
|
|
46
|
-
this.ownerHost = ownerHost;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
45
|
function decisionFor(error, peerPrincipal = false) {
|
|
50
|
-
if (error instanceof ConsentOwnerError)
|
|
51
|
-
return { accepted: false, code: "conflict", ownerHost: error.ownerHost };
|
|
52
46
|
if (!(error instanceof BridgeError))
|
|
53
47
|
return { accepted: false, code: "rejected" };
|
|
54
48
|
if (error.code.includes("NOT_FOUND") || error.code.includes("NOT_AVAILABLE")) {
|
|
@@ -63,11 +57,42 @@ function decisionFor(error, peerPrincipal = false) {
|
|
|
63
57
|
if (error.code.includes("UNAVAILABLE") || error.code.includes("OFFLINE") || error.code.includes("UNOBSERVED")) {
|
|
64
58
|
return { accepted: false, code: "unavailable" };
|
|
65
59
|
}
|
|
66
|
-
if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") ||
|
|
60
|
+
if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") ||
|
|
61
|
+
error.code.includes("CHANGED") || error.code.includes("ADDRESS")) {
|
|
67
62
|
return { accepted: false, code: "route_mismatch" };
|
|
68
63
|
}
|
|
69
64
|
return { accepted: false, code: "rejected" };
|
|
70
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The refusal a send returns: the coarse decision clients branch on,
|
|
68
|
+
* plus the safe code behind it so the CLI can name the one remedy that fits.
|
|
69
|
+
* Only a BridgeError's own code is carried; anything else stays anonymous.
|
|
70
|
+
*/
|
|
71
|
+
function sendRefusal(error, peerPrincipal = false) {
|
|
72
|
+
const decision = decisionFor(error, peerPrincipal);
|
|
73
|
+
const reason = error instanceof BridgeError && SAFE_CODE.test(error.code) ? error.code : undefined;
|
|
74
|
+
return { accepted: false, code: decision.code, ...(reason === undefined ? {} : { reason }) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A grammar-valid alias for a session that cannot have the name it discovered
|
|
78
|
+
* under, because a live session already holds it. The suffix is derived from
|
|
79
|
+
* the session's own route handle, so it is stable across sends, and it is a
|
|
80
|
+
* hash rather than the handle itself: a native session UUID must never enter a
|
|
81
|
+
* public alias.
|
|
82
|
+
*/
|
|
83
|
+
function disambiguatedAlias(alias, routeHandle) {
|
|
84
|
+
const at = alias.lastIndexOf("@");
|
|
85
|
+
const name = alias.slice(0, at), host = alias.slice(at + 1);
|
|
86
|
+
const suffix = createHash("sha256").update(routeHandle).digest("hex").slice(0, 8);
|
|
87
|
+
return `${name.slice(0, 32 - suffix.length - 1)}-${suffix}@${host}`;
|
|
88
|
+
}
|
|
89
|
+
/** The decoder already accepted exactly one credential, so the last arm holds. */
|
|
90
|
+
function replyCaller(params) {
|
|
91
|
+
const { fromAlias, threadId, replyAddress, peerToken } = params;
|
|
92
|
+
return threadId !== undefined ? { kind: "codex", alias: fromAlias, threadId }
|
|
93
|
+
: replyAddress !== undefined ? { kind: "claude", alias: fromAlias, replyAddress }
|
|
94
|
+
: { kind: "peer", alias: fromAlias, token: peerToken };
|
|
95
|
+
}
|
|
71
96
|
function sameBinding(left, right) {
|
|
72
97
|
return left.provider === right.provider && left.hostId === right.hostId &&
|
|
73
98
|
left.routeHandle === right.routeHandle && left.registrationId === right.registrationId;
|
|
@@ -100,10 +125,7 @@ function bodyHash(body) {
|
|
|
100
125
|
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
101
126
|
}
|
|
102
127
|
function peerCatalogAuthorityChanged(prior, current) {
|
|
103
|
-
const authority = (catalog) => JSON.stringify(
|
|
104
|
-
catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
105
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
106
|
-
]);
|
|
128
|
+
const authority = (catalog) => JSON.stringify(catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)));
|
|
107
129
|
return prior === undefined || authority(prior) !== authority(current);
|
|
108
130
|
}
|
|
109
131
|
function peerCatalogViewChanged(prior, current) {
|
|
@@ -111,7 +133,6 @@ function peerCatalogViewChanged(prior, current) {
|
|
|
111
133
|
catalog.health,
|
|
112
134
|
catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
|
|
113
135
|
catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
114
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
115
136
|
catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
|
|
116
137
|
]);
|
|
117
138
|
return prior === undefined || view(prior) !== view(current);
|
|
@@ -123,11 +144,10 @@ export class GatewayService {
|
|
|
123
144
|
config;
|
|
124
145
|
store;
|
|
125
146
|
adapters;
|
|
126
|
-
publishDashboard;
|
|
127
147
|
now;
|
|
128
148
|
timers;
|
|
129
149
|
nativePeerCwd;
|
|
130
|
-
|
|
150
|
+
managedCodexSocketHeld;
|
|
131
151
|
spawnPeer;
|
|
132
152
|
peerClients = new Map();
|
|
133
153
|
peerCatalogs = new Map();
|
|
@@ -146,18 +166,25 @@ export class GatewayService {
|
|
|
146
166
|
peerRefreshOperations = new Set();
|
|
147
167
|
pendingClaudeReplies = new Map();
|
|
148
168
|
nativeReceipts = new Map();
|
|
149
|
-
progressWatches = new Map();
|
|
150
|
-
progressWatchEvents = [];
|
|
151
169
|
dispatchRunners = new Map();
|
|
152
170
|
steerRunners = new Map();
|
|
153
171
|
startingTargets = new Set();
|
|
154
172
|
runtimeAlerts = [];
|
|
173
|
+
/**
|
|
174
|
+
* Ephemeral peer registrations and the wall-clock instant each one dies.
|
|
175
|
+
* Nothing durable records them, so this map is the whole lifetime: it is
|
|
176
|
+
* swept on the ordinary maintenance tick, and a restart simply never sees
|
|
177
|
+
* the routes at all.
|
|
178
|
+
*/
|
|
179
|
+
ephemeralPeerExpiries = new Map();
|
|
180
|
+
/** Consecutive failed retirements per ephemeral alias; cleared on success or removal. */
|
|
181
|
+
ephemeralExpiryFailures = new Map();
|
|
155
182
|
control;
|
|
156
183
|
wakeTimer;
|
|
157
184
|
nextDiscoveryAt = 0;
|
|
158
|
-
|
|
185
|
+
nextManagedCodexCheckAt = 0;
|
|
159
186
|
nextPeerRefreshAt = 0;
|
|
160
|
-
|
|
187
|
+
managedCodexSocketHeldOutside = false;
|
|
161
188
|
revision = 0;
|
|
162
189
|
snapshotRevision = 0;
|
|
163
190
|
snapshotFingerprint;
|
|
@@ -168,10 +195,9 @@ export class GatewayService {
|
|
|
168
195
|
this.config = options.config;
|
|
169
196
|
this.adapters = [...(options.adapters ?? [])];
|
|
170
197
|
this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
|
|
171
|
-
this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
|
|
172
198
|
this.now = options.now ?? (() => new Date());
|
|
173
199
|
this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
|
|
174
|
-
this.
|
|
200
|
+
this.managedCodexSocketHeld = options.managedCodexSocketHeld;
|
|
175
201
|
this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
|
|
176
202
|
this.timers = options.timers ?? {
|
|
177
203
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
@@ -216,7 +242,7 @@ export class GatewayService {
|
|
|
216
242
|
}
|
|
217
243
|
}
|
|
218
244
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
219
|
-
await this.
|
|
245
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
220
246
|
const control = await startGatewayControlServer({
|
|
221
247
|
stateDir: this.store.rootDir,
|
|
222
248
|
socketPath: this.config.controlSocketPath,
|
|
@@ -235,11 +261,10 @@ export class GatewayService {
|
|
|
235
261
|
await this.observeLoadedRoutes();
|
|
236
262
|
const now = this.now().getTime();
|
|
237
263
|
this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
|
|
238
|
-
this.
|
|
264
|
+
this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
239
265
|
this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
|
|
240
266
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
241
267
|
this.kick(target);
|
|
242
|
-
await this.publish();
|
|
243
268
|
this.scheduleWake();
|
|
244
269
|
assertActive();
|
|
245
270
|
}
|
|
@@ -312,7 +337,6 @@ export class GatewayService {
|
|
|
312
337
|
this.assertWritable();
|
|
313
338
|
await operation();
|
|
314
339
|
this.revision += 1;
|
|
315
|
-
await this.publish();
|
|
316
340
|
return { accepted: true, code: "ok" };
|
|
317
341
|
}
|
|
318
342
|
catch (error) {
|
|
@@ -327,7 +351,6 @@ export class GatewayService {
|
|
|
327
351
|
this.assertWritable();
|
|
328
352
|
const token = await this.registerPeer(params);
|
|
329
353
|
this.revision += 1;
|
|
330
|
-
await this.publish();
|
|
331
354
|
return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
|
|
332
355
|
}
|
|
333
356
|
catch (error) {
|
|
@@ -338,24 +361,24 @@ export class GatewayService {
|
|
|
338
361
|
awaitPeer: (params) => this.awaitPeer(params),
|
|
339
362
|
peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
|
|
340
363
|
unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
|
|
341
|
-
removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
|
|
342
|
-
selectClaude: (params) => decide(async () => this.selectClaude(params)),
|
|
343
|
-
unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
|
|
344
|
-
pair: (params) => decide(async () => this.pair(params)),
|
|
345
|
-
unpair: (params) => decide(async () => this.unpair(params)),
|
|
346
364
|
listSnapshot: () => this.snapshot(),
|
|
347
365
|
observeSnapshot: () => this.observeSnapshot(),
|
|
348
366
|
deliveryStatus: (params) => this.deliveryStatus(params.token),
|
|
349
|
-
untrack: (params) => decide(async () => this.untrack(params.conversationId)),
|
|
350
367
|
send: (params) => this.send(params),
|
|
351
|
-
|
|
352
|
-
refreshDashboard: async () => {
|
|
368
|
+
refreshDiscovery: async () => {
|
|
353
369
|
if (this.closing) {
|
|
354
370
|
return { accepted: false, code: "unavailable", revision: this.revision };
|
|
355
371
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
372
|
+
try {
|
|
373
|
+
// An operator-triggered rescan reports its own failure. It must never
|
|
374
|
+
// journal `discovery_refreshed` for a scan that did not complete, and
|
|
375
|
+
// must never answer `accepted: true` for a provider that is down.
|
|
376
|
+
await this.refreshClaudeDiscovery();
|
|
377
|
+
await this.recordActivity("discovery", "discovery_refreshed", [], true);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return { accepted: false, code: decisionFor(error).code, revision: this.revision };
|
|
381
|
+
}
|
|
359
382
|
return { accepted: true, code: "ok", revision: this.revision };
|
|
360
383
|
},
|
|
361
384
|
peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
|
|
@@ -366,12 +389,15 @@ export class GatewayService {
|
|
|
366
389
|
const localHost = this.config.hostId;
|
|
367
390
|
if (!this.config.peerNodes.includes(peerHost))
|
|
368
391
|
throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
|
|
369
|
-
const [snapshot, privateRoutes
|
|
370
|
-
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
392
|
+
const [snapshot, privateRoutes] = await Promise.all([
|
|
393
|
+
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
371
394
|
]);
|
|
372
|
-
|
|
395
|
+
// An ephemeral registration is never published: a peer node that mirrored
|
|
396
|
+
// one would hold a route this broker will delete on a timer and can never
|
|
397
|
+
// restore, and a self-test must not leave a trace on another machine.
|
|
398
|
+
const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" &&
|
|
399
|
+
route.binding.hostId === localHost && !this.store.isEphemeralRegistration(route.binding.registrationId));
|
|
373
400
|
const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
|
|
374
|
-
const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
|
|
375
401
|
const routes = localRoutes.map((route) => {
|
|
376
402
|
const row = publicRoutes.get(route.alias);
|
|
377
403
|
return {
|
|
@@ -381,27 +407,13 @@ export class GatewayService {
|
|
|
381
407
|
...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
382
408
|
};
|
|
383
409
|
});
|
|
384
|
-
const consentEdges = privateEdges.flatMap((edge) => {
|
|
385
|
-
if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
|
|
386
|
-
return [];
|
|
387
|
-
const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
|
|
388
|
-
if (rows[0] === undefined || rows[1] === undefined ||
|
|
389
|
-
!rows.some((route) => route?.binding.hostId === peerHost) ||
|
|
390
|
-
!rows.some((route) => route?.binding.hostId === localHost))
|
|
391
|
-
return [];
|
|
392
|
-
const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
|
|
393
|
-
host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
|
|
394
|
-
? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
|
|
395
|
-
const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
|
|
396
|
-
return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
|
|
397
|
-
});
|
|
398
410
|
return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
|
|
399
411
|
health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
|
|
400
412
|
provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
|
|
401
413
|
...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
|
|
402
414
|
...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
|
|
403
415
|
...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
404
|
-
})), routes,
|
|
416
|
+
})), routes, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
|
|
405
417
|
}
|
|
406
418
|
async receivePeerHandoff(peerHost, handoff) {
|
|
407
419
|
if (!this.config.peerNodes.includes(peerHost))
|
|
@@ -415,12 +427,11 @@ export class GatewayService {
|
|
|
415
427
|
const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
|
|
416
428
|
this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
|
|
417
429
|
sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
|
|
418
|
-
|
|
430
|
+
nextSequence: 1 });
|
|
419
431
|
this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
|
|
420
432
|
this.kick(target.alias, target.binding.registrationId);
|
|
421
433
|
}
|
|
422
434
|
this.revision += 1;
|
|
423
|
-
await this.publish();
|
|
424
435
|
return { accepted: true };
|
|
425
436
|
}
|
|
426
437
|
async snapshot() {
|
|
@@ -454,25 +465,17 @@ export class GatewayService {
|
|
|
454
465
|
const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
|
|
455
466
|
const stale = runtime.health === "healthy" &&
|
|
456
467
|
(age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
|
|
457
|
-
const
|
|
458
|
-
const doctorConditions = doctor === undefined
|
|
459
|
-
? [] : [...new Set(doctor.conditions)];
|
|
460
|
-
if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
|
|
461
|
-
doctorConditions.push("observation_stale");
|
|
462
|
-
const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
|
|
468
|
+
const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
|
|
463
469
|
const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
|
|
464
|
-
(
|
|
470
|
+
(socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
|
|
465
471
|
return {
|
|
466
472
|
provider: runtime.adapter.identity.provider,
|
|
467
473
|
host: runtime.adapter.identity.hostId,
|
|
468
|
-
health: stale ||
|
|
474
|
+
health: stale || socketHeld ? "degraded" : runtime.health,
|
|
469
475
|
protocol: runtime.adapter.protocol,
|
|
470
476
|
protocolVersion: runtime.adapter.protocolVersion,
|
|
471
477
|
...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
|
|
472
478
|
...(age === undefined ? {} : { observationAgeMs: age }),
|
|
473
|
-
...(doctorConditions.length === 0 ? {} : {
|
|
474
|
-
codexDoctor: { conditions: doctorConditions.slice(0, 2) },
|
|
475
|
-
}),
|
|
476
479
|
...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
|
|
477
480
|
...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
|
|
478
481
|
};
|
|
@@ -486,20 +489,9 @@ export class GatewayService {
|
|
|
486
489
|
host: candidate.adapter.identity.hostId,
|
|
487
490
|
state: candidate.state,
|
|
488
491
|
validated: true,
|
|
489
|
-
|
|
492
|
+
routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
|
|
490
493
|
lastSeenAt: candidate.observedAt,
|
|
491
494
|
}));
|
|
492
|
-
const progressWatches = [...this.progressWatches.values()]
|
|
493
|
-
.slice(0, gatewayPublicSnapshotLimits.progressWatches)
|
|
494
|
-
.map((watch) => ({
|
|
495
|
-
conversationIdSuffix: watch.conversationId.slice(-8),
|
|
496
|
-
ownerAlias: watch.ownerAlias,
|
|
497
|
-
workerAlias: watch.workerAlias,
|
|
498
|
-
lastActivityAt: watch.lastActivityAt,
|
|
499
|
-
nextActionAt: watch.nextActionAt,
|
|
500
|
-
nudgeCount: watch.nudgeCount,
|
|
501
|
-
}));
|
|
502
|
-
const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
|
|
503
495
|
return projectGatewayPublicSnapshot({
|
|
504
496
|
...base,
|
|
505
497
|
generatedAt: now.toISOString(),
|
|
@@ -511,14 +503,8 @@ export class GatewayService {
|
|
|
511
503
|
connectors,
|
|
512
504
|
availablePeers,
|
|
513
505
|
routes,
|
|
514
|
-
progressWatches,
|
|
515
|
-
progressWatchEvents,
|
|
516
506
|
alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
|
|
517
|
-
truncation:
|
|
518
|
-
...base.truncation,
|
|
519
|
-
progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
|
|
520
|
-
progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
|
|
521
|
-
},
|
|
507
|
+
truncation: base.truncation,
|
|
522
508
|
}, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
|
|
523
509
|
}
|
|
524
510
|
async observeSnapshot() {
|
|
@@ -617,7 +603,7 @@ export class GatewayService {
|
|
|
617
603
|
async reconcileUnadvertisement(route) {
|
|
618
604
|
await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
|
|
619
605
|
}
|
|
620
|
-
async removeOwnedRoute(route
|
|
606
|
+
async removeOwnedRoute(route) {
|
|
621
607
|
this.assertWritable();
|
|
622
608
|
const result = await this.store.removeOwnedRouteAtomic({
|
|
623
609
|
alias: route.alias,
|
|
@@ -625,16 +611,10 @@ export class GatewayService {
|
|
|
625
611
|
activity: { operatorAction: true },
|
|
626
612
|
});
|
|
627
613
|
if (!result.removed)
|
|
628
|
-
throw new BridgeError(
|
|
614
|
+
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
|
|
629
615
|
await this.finishSettlements(result.settlements);
|
|
630
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
631
616
|
this.forgetRoute(route);
|
|
632
|
-
|
|
633
|
-
await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
|
|
634
|
-
}
|
|
635
|
-
else {
|
|
636
|
-
await this.reconcileUnadvertisement(route);
|
|
637
|
-
}
|
|
617
|
+
await this.reconcileUnadvertisement(route);
|
|
638
618
|
}
|
|
639
619
|
async recordActivity(kind, action, aliases, operatorAction) {
|
|
640
620
|
await this.store.recordActivity({
|
|
@@ -695,7 +675,6 @@ export class GatewayService {
|
|
|
695
675
|
activity: { operatorAction: true },
|
|
696
676
|
});
|
|
697
677
|
await this.finishSettlements(result.settlements);
|
|
698
|
-
this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
|
|
699
678
|
if (!result.idempotent) {
|
|
700
679
|
this.forgetRoute(previous);
|
|
701
680
|
this.reconcileUnadvertisement(previous);
|
|
@@ -726,8 +705,13 @@ export class GatewayService {
|
|
|
726
705
|
await this.store.registerRoute({ alias: params.alias, binding: {
|
|
727
706
|
provider: "peer", hostId: this.config.hostId,
|
|
728
707
|
routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
|
|
729
|
-
}, registrationMode: "explicit_opt_in"
|
|
708
|
+
}, registrationMode: "explicit_opt_in",
|
|
709
|
+
...(params.ephemeral === true ? { ephemeral: true } : {}) });
|
|
730
710
|
const installed = (await this.store.inspectPrivateRoute(params.alias));
|
|
711
|
+
if (params.ephemeral === true) {
|
|
712
|
+
this.ephemeralPeerExpiries.set(installed.alias, this.now().getTime() + (params.ttlMs ?? GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS));
|
|
713
|
+
this.scheduleWake();
|
|
714
|
+
}
|
|
731
715
|
this.observeRoute(installed);
|
|
732
716
|
this.reconcileAdvertisement(installed);
|
|
733
717
|
return token;
|
|
@@ -748,19 +732,86 @@ export class GatewayService {
|
|
|
748
732
|
if (this.peerCleanupAliases.has(route.alias))
|
|
749
733
|
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
750
734
|
this.peerCleanupAliases.add(route.alias);
|
|
735
|
+
// The expiry leaves the map before the removal, under the same cleanup
|
|
736
|
+
// guard, so a clock that fires mid-removal has nothing to act on; a
|
|
737
|
+
// removal that does not happen puts it back, so a refused unregister
|
|
738
|
+
// still leaves the registration to its own expiry.
|
|
739
|
+
const expiry = this.ephemeralPeerExpiries.get(route.alias);
|
|
740
|
+
this.ephemeralPeerExpiries.delete(route.alias);
|
|
741
|
+
this.ephemeralExpiryFailures.delete(route.alias);
|
|
742
|
+
let removed = false;
|
|
751
743
|
try {
|
|
752
744
|
const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
|
|
753
745
|
if (!result.removed)
|
|
754
746
|
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
747
|
+
removed = true;
|
|
755
748
|
await this.finishSettlements(result.settlements);
|
|
756
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
757
749
|
this.forgetRoute(route);
|
|
758
750
|
await this.reconcileUnadvertisement(route);
|
|
759
751
|
}
|
|
760
752
|
finally {
|
|
753
|
+
if (!removed && expiry !== undefined)
|
|
754
|
+
this.ephemeralPeerExpiries.set(route.alias, expiry);
|
|
761
755
|
this.peerCleanupAliases.delete(route.alias);
|
|
762
756
|
}
|
|
763
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* Retires every ephemeral peer registration whose lifetime has run out. It
|
|
760
|
+
* is the same removal an explicit `unregister-peer` performs — queued work
|
|
761
|
+
* is settled, not abandoned — minus the token check, because the authority
|
|
762
|
+
* here is the clock the broker set itself.
|
|
763
|
+
*
|
|
764
|
+
* The clock is keyed by alias, and an alias can be re-registered durably
|
|
765
|
+
* after its ephemeral holder left, so nothing is retired unless the route
|
|
766
|
+
* behind the alias is still an ephemeral one. A retirement that throws
|
|
767
|
+
* keeps its entry and is retried `EPHEMERAL_EXPIRY_RETRY_MS` later, with an
|
|
768
|
+
* alert each time; after `EPHEMERAL_EXPIRY_MAX_ATTEMPTS` consecutive
|
|
769
|
+
* failures the entry is dropped and the registration is left to the next
|
|
770
|
+
* restart, which clears it by construction.
|
|
771
|
+
*/
|
|
772
|
+
async expireEphemeralPeers(now) {
|
|
773
|
+
for (const [alias, expiresAt] of [...this.ephemeralPeerExpiries]) {
|
|
774
|
+
if (expiresAt > now)
|
|
775
|
+
continue;
|
|
776
|
+
// Another owner is mid-removal; the entry is theirs to clear or restore.
|
|
777
|
+
if (this.peerCleanupAliases.has(alias))
|
|
778
|
+
continue;
|
|
779
|
+
const route = await this.store.inspectPrivateRoute(alias);
|
|
780
|
+
if (route === undefined || !this.store.isEphemeralRegistration(route.binding.registrationId)) {
|
|
781
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
782
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
this.peerCleanupAliases.add(alias);
|
|
786
|
+
try {
|
|
787
|
+
const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding });
|
|
788
|
+
if (result.removed) {
|
|
789
|
+
await this.finishSettlements(result.settlements);
|
|
790
|
+
this.forgetRoute(route);
|
|
791
|
+
await this.reconcileUnadvertisement(route);
|
|
792
|
+
}
|
|
793
|
+
// Not removed means the binding changed underneath: the alias belongs
|
|
794
|
+
// to someone else now, and this clock is spent either way.
|
|
795
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
796
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
this.alert("EPHEMERAL_ROUTE_EXPIRY_FAILED", route, error);
|
|
800
|
+
const attempts = (this.ephemeralExpiryFailures.get(alias) ?? 0) + 1;
|
|
801
|
+
if (attempts >= EPHEMERAL_EXPIRY_MAX_ATTEMPTS) {
|
|
802
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
803
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
this.ephemeralExpiryFailures.set(alias, attempts);
|
|
807
|
+
this.ephemeralPeerExpiries.set(alias, now + EPHEMERAL_EXPIRY_RETRY_MS);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
this.peerCleanupAliases.delete(alias);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
764
815
|
async awaitPeer(params) {
|
|
765
816
|
this.assertWritable();
|
|
766
817
|
const route = await this.assertPeer(params);
|
|
@@ -785,33 +836,56 @@ export class GatewayService {
|
|
|
785
836
|
route.binding.routeHandle !== params.threadId) {
|
|
786
837
|
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
|
|
787
838
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
839
|
+
if (route.registrationMode === "federated_peer") {
|
|
840
|
+
throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node and cannot be unregistered here.");
|
|
841
|
+
}
|
|
842
|
+
await this.removeOwnedRoute(route);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The name this session may take. A name belongs to one route at a time, so
|
|
846
|
+
* when another Claude route already holds it and THAT route's session is
|
|
847
|
+
* still live, this session takes a disambiguated name rather than displacing
|
|
848
|
+
* a working session and cancelling its queued work. Twins that share a
|
|
849
|
+
* display name therefore both keep routes and both stay reachable by UUID;
|
|
850
|
+
* only a route whose session is actually gone is displaced.
|
|
851
|
+
*/
|
|
852
|
+
claimableAlias(candidate, routes) {
|
|
853
|
+
const holder = routes.find((route) => route.alias === candidate.alias &&
|
|
854
|
+
route.binding.provider === "claude" &&
|
|
855
|
+
!(route.binding.hostId === candidate.adapter.identity.hostId &&
|
|
856
|
+
route.binding.routeHandle === candidate.routeHandle));
|
|
857
|
+
if (holder === undefined)
|
|
858
|
+
return candidate.alias;
|
|
859
|
+
const holderIsLive = [...this.candidates.values()].some((row) => row.adapter.identity.hostId === holder.binding.hostId &&
|
|
860
|
+
row.routeHandle === holder.binding.routeHandle);
|
|
861
|
+
return holderIsLive
|
|
862
|
+
? disambiguatedAlias(candidate.alias, candidate.routeHandle)
|
|
863
|
+
: candidate.alias;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Installs (or brings up to date) the route of one discovered Claude
|
|
867
|
+
* session. The adapter pins the session's route handle, the workspace
|
|
868
|
+
* check keeps deliberately broad workspaces out, and the store reuses the
|
|
869
|
+
* registration of a session already bound under the same (host, UUID) so a
|
|
870
|
+
* re-anchored or renamed session keeps its identity and in-flight
|
|
871
|
+
* conversations. Any Claude route the install displaced is forgotten and
|
|
872
|
+
* released at the adapter; its work was settled `ENDPOINT_RETIRED` by the
|
|
873
|
+
* store — and only a route whose own session is gone is ever displaced.
|
|
874
|
+
*/
|
|
875
|
+
async installClaudeRoute(candidate) {
|
|
876
|
+
const adapter = candidate.adapter;
|
|
803
877
|
if (adapter.selectRoute === undefined) {
|
|
804
|
-
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot
|
|
878
|
+
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot install routes.");
|
|
805
879
|
}
|
|
806
|
-
await adapter.assertWorkspaceDisjoint?.(
|
|
807
|
-
const chosen = await adapter.selectRoute({ alias:
|
|
880
|
+
await adapter.assertWorkspaceDisjoint?.(candidate.routeHandle, this.store.rootDir);
|
|
881
|
+
const chosen = await adapter.selectRoute({ alias: candidate.alias, routeHandle: candidate.routeHandle });
|
|
808
882
|
this.assertWritable();
|
|
809
883
|
const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
810
|
-
const
|
|
811
|
-
|
|
884
|
+
const alias = this.claimableAlias(candidate, before);
|
|
885
|
+
const existing = before.find((route) => route.binding.hostId === adapter.identity.hostId &&
|
|
812
886
|
route.binding.routeHandle === chosen.routeHandle);
|
|
813
|
-
const
|
|
814
|
-
alias
|
|
887
|
+
const input = {
|
|
888
|
+
alias,
|
|
815
889
|
binding: {
|
|
816
890
|
provider: "claude",
|
|
817
891
|
hostId: adapter.identity.hostId,
|
|
@@ -819,114 +893,165 @@ export class GatewayService {
|
|
|
819
893
|
registrationId: existing?.binding.registrationId ?? registrationId(),
|
|
820
894
|
},
|
|
821
895
|
registrationMode: "selected_live_peer",
|
|
822
|
-
}
|
|
896
|
+
};
|
|
897
|
+
const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
|
|
823
898
|
await this.finishSettlements(result.settlements);
|
|
824
899
|
const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
825
900
|
for (const prior of before) {
|
|
826
|
-
const retained = after.find((route) => route.
|
|
827
|
-
if (retained === undefined
|
|
828
|
-
|
|
901
|
+
const retained = after.find((route) => route.binding.registrationId === prior.binding.registrationId);
|
|
902
|
+
if (retained === undefined) {
|
|
903
|
+
// Displaced: the alias now names another session.
|
|
829
904
|
this.forgetRoute(prior);
|
|
830
905
|
void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
|
|
831
906
|
.catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
|
|
832
907
|
}
|
|
908
|
+
else if (retained.alias !== prior.alias) {
|
|
909
|
+
// Renamed: identity kept, the stale display-only observation goes and
|
|
910
|
+
// open conversations follow the session to its new name.
|
|
911
|
+
this.routeObservations.delete(prior.alias);
|
|
912
|
+
this.renameConversationCoordinates(prior.alias, retained.alias, prior.binding.registrationId);
|
|
913
|
+
}
|
|
833
914
|
}
|
|
834
|
-
const installed = (await this.store.inspectPrivateRoute(
|
|
915
|
+
const installed = (await this.store.inspectPrivateRoute(alias));
|
|
835
916
|
this.observeRoute(installed);
|
|
836
|
-
this.routeObservations.set(
|
|
917
|
+
this.routeObservations.set(alias, {
|
|
837
918
|
route: installed.binding,
|
|
838
919
|
state: chosen.state,
|
|
839
920
|
observedAt: this.now().toISOString(),
|
|
840
921
|
});
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
this.assertWritable();
|
|
857
|
-
const existed = await this.store.hasConsentEdge(endpoints);
|
|
858
|
-
await this.store.addConsentEdge(endpoints);
|
|
859
|
-
if (!existed)
|
|
860
|
-
await this.recordActivity("pairing", "routes_paired", [...aliases], true);
|
|
861
|
-
}
|
|
862
|
-
async unpair(params) {
|
|
863
|
-
const endpoints = await this.resolvePairEndpoints(params, false);
|
|
864
|
-
const aliases = endpoints.aliases;
|
|
865
|
-
const ownerHost = aliases.map(aliasHost).sort()[0];
|
|
866
|
-
if (this.config.hostId !== ownerHost)
|
|
867
|
-
throw new ConsentOwnerError(ownerHost);
|
|
868
|
-
this.assertWritable();
|
|
869
|
-
const existed = await this.store.hasConsentEdge(endpoints);
|
|
870
|
-
const result = await this.store.removeConsentEdge(endpoints);
|
|
871
|
-
await this.finishSettlements(result.settlements);
|
|
872
|
-
for (const alias of aliases)
|
|
873
|
-
this.settleWatchesForAlias(alias, "pair_removed", "operator");
|
|
874
|
-
if (existed)
|
|
875
|
-
await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
|
|
876
|
-
for (const alias of aliases)
|
|
877
|
-
this.kick(alias);
|
|
878
|
-
}
|
|
879
|
-
async resolvePairEndpoints(params, maySelect) {
|
|
880
|
-
const raw = params.aliases;
|
|
881
|
-
const aliases = [raw[0], raw[1]];
|
|
882
|
-
if (maySelect && aliases.some((alias) => PUBLIC_ALIAS.test(alias)))
|
|
883
|
-
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
884
|
-
for (let index = 0; index < aliases.length; index += 1) {
|
|
885
|
-
if (maySelect && this.collidingClaudeAliases.has(aliases[index]))
|
|
886
|
-
throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
|
|
887
|
-
if (PUBLIC_ALIAS.test(aliases[index]))
|
|
888
|
-
continue;
|
|
889
|
-
const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
|
|
890
|
-
if (selected === undefined)
|
|
891
|
-
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
|
|
892
|
-
if (maySelect)
|
|
893
|
-
await this.selectClaude({ alias: aliases[index] });
|
|
894
|
-
aliases[index] = selected.alias;
|
|
895
|
-
}
|
|
896
|
-
const left = await this.store.inspectPrivateRoute(aliases[0]);
|
|
897
|
-
const right = await this.store.inspectPrivateRoute(aliases[1]);
|
|
898
|
-
if (left === undefined || right === undefined) {
|
|
899
|
-
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The pair endpoint is absent.");
|
|
922
|
+
if (result.installed)
|
|
923
|
+
this.revision += 1;
|
|
924
|
+
this.kick(alias);
|
|
925
|
+
return installed;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Two first sends to the same never-installed session race: both read no
|
|
929
|
+
* route and both mint a registration, and the loser's write is refused
|
|
930
|
+
* because the identity is now bound. That is a race, not a conflict — the
|
|
931
|
+
* session is exactly the one the loser resolved — so re-read once and adopt
|
|
932
|
+
* the registration that won instead of failing an honest send.
|
|
933
|
+
*/
|
|
934
|
+
async installWithConcurrentFirstSend(input, hostId, routeHandle) {
|
|
935
|
+
try {
|
|
936
|
+
return await this.store.installClaudeRoute(input);
|
|
900
937
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (!(error instanceof BridgeError) || error.code !== "ROUTE_IDENTITY_ALREADY_REGISTERED")
|
|
940
|
+
throw error;
|
|
941
|
+
const bound = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
942
|
+
route.binding.hostId === hostId &&
|
|
943
|
+
route.binding.routeHandle === routeHandle);
|
|
944
|
+
if (bound === undefined)
|
|
945
|
+
throw error;
|
|
946
|
+
return await this.store.installClaudeRoute({
|
|
947
|
+
...input,
|
|
948
|
+
binding: { ...input.binding, registrationId: bound.binding.registrationId },
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Looks a Claude selector up without writing anything. Callers that may
|
|
954
|
+
* still refuse — a `--from` alias that must match the sending session, a
|
|
955
|
+
* native sender whose adapter-reported name must match discovery — resolve
|
|
956
|
+
* here first, refuse on the read, and only then materialize.
|
|
957
|
+
*
|
|
958
|
+
* THE ALIAS-COLLISION FENCE lives here (emb-94, moved into the send path by
|
|
959
|
+
* emb-104). Invariant: discovery is refreshed inside every send that
|
|
960
|
+
* addresses a Claude session, never only on the timer; a NAME currently
|
|
961
|
+
* shared by more than one live session is refused with a hard,
|
|
962
|
+
* non-retryable `PEER_ALIAS_COLLISION` naming that alias, so the broker
|
|
963
|
+
* never picks first and never installs a route under an ambiguous name;
|
|
964
|
+
* collisions stay sticky while a scan is incomplete
|
|
965
|
+
* (`collidingClaudeAliases` retention in refreshClaudeDiscovery); and
|
|
966
|
+
* `availablePeers` keeps filtering colliders so `status` never lists an
|
|
967
|
+
* unaddressable name.
|
|
968
|
+
*
|
|
969
|
+
* The fence is a fence on NAMES, not on sessions. A session UUID is
|
|
970
|
+
* unambiguous by construction, so a UUID selector — an explicit
|
|
971
|
+
* `send --to <uuid>`, an identity-pinned route addressed by its own handle,
|
|
972
|
+
* or a sender resolved from its inherited socket — passes the fence even
|
|
973
|
+
* while its display name collides. That is the emb-94 escape hatch: the
|
|
974
|
+
* operator can always reach an ambiguously named session, and a collision on
|
|
975
|
+
* a *sender's* name never silences that sender, whose identity was attested
|
|
976
|
+
* rather than typed. In-flight conversations are likewise unaffected:
|
|
977
|
+
* `reply` resolves by exact binding.
|
|
978
|
+
*/
|
|
979
|
+
async lookUpClaudeRoute(selector, hostId) {
|
|
980
|
+
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
981
|
+
if (PUBLIC_ALIAS.test(selector) && this.collidingClaudeAliases.has(selector)) {
|
|
982
|
+
throw new BridgeError("PEER_ALIAS_COLLISION", `The Claude alias ${selector} names more than one live session; rename one, or address the session by UUID.`);
|
|
983
|
+
}
|
|
984
|
+
const routed = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
985
|
+
(hostId === undefined || route.binding.hostId === hostId) &&
|
|
986
|
+
(route.alias === selector || route.binding.routeHandle === selector));
|
|
987
|
+
const candidate = [...this.candidates.values()].find((row) => (hostId === undefined || row.adapter.identity.hostId === hostId) &&
|
|
988
|
+
(row.routeHandle === selector || row.alias === selector));
|
|
989
|
+
return { routed, candidate };
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The name discovery currently shows for a looked-up session, which a caller
|
|
993
|
+
* compares against the alias it claimed before anything is written.
|
|
994
|
+
*/
|
|
995
|
+
claimedAlias(resolution) {
|
|
996
|
+
const alias = resolution.candidate?.alias ?? resolution.routed?.alias;
|
|
997
|
+
if (alias === undefined) {
|
|
998
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
999
|
+
}
|
|
1000
|
+
return alias;
|
|
1001
|
+
}
|
|
1002
|
+
/** Installs the looked-up session's route if it is not already current. */
|
|
1003
|
+
async materializeClaudeRoute(resolution) {
|
|
1004
|
+
const { routed, candidate } = resolution;
|
|
1005
|
+
if (candidate === undefined) {
|
|
1006
|
+
if (routed !== undefined)
|
|
1007
|
+
return routed;
|
|
1008
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
1009
|
+
}
|
|
1010
|
+
if (routed !== undefined &&
|
|
1011
|
+
routed.binding.hostId === candidate.adapter.identity.hostId &&
|
|
1012
|
+
routed.binding.routeHandle === candidate.routeHandle &&
|
|
1013
|
+
routed.alias === this.claimableAlias(candidate, await this.store.listLogicalRoutes()))
|
|
1014
|
+
return routed;
|
|
1015
|
+
return await this.installClaudeRoute(candidate);
|
|
1016
|
+
}
|
|
1017
|
+
async resolveClaudeRoute(selector, hostId) {
|
|
1018
|
+
return await this.materializeClaudeRoute(await this.lookUpClaudeRoute(selector, hostId));
|
|
908
1019
|
}
|
|
909
1020
|
async assertThread(alias, threadId) {
|
|
910
1021
|
const route = await this.store.inspectPrivateRoute(alias);
|
|
911
|
-
|
|
1022
|
+
// No registration at all is its own condition — the state every Codex
|
|
1023
|
+
// task is in after a private state reset — and is never dressed up as a
|
|
1024
|
+
// thread mismatch, which would send the operator hunting a wrong id.
|
|
1025
|
+
if (route === undefined)
|
|
1026
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route is not registered.");
|
|
1027
|
+
if (route.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
|
|
912
1028
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
|
|
913
1029
|
}
|
|
914
1030
|
return route;
|
|
915
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Direction follows the inherited principal, not the route table: a Claude
|
|
1034
|
+
* session or its target may have no route yet, because routes install on
|
|
1035
|
+
* first send. A Codex task sends to Claude sessions and shell peers; a
|
|
1036
|
+
* Claude session sends to Codex tasks and shell peers (Claude-to-Claude is
|
|
1037
|
+
* native); a shell peer sends to either.
|
|
1038
|
+
*/
|
|
916
1039
|
async send(params) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const target =
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1040
|
+
if (params.conversationId !== undefined)
|
|
1041
|
+
return await this.replyToConversation(params);
|
|
1042
|
+
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
1043
|
+
if ("replyAddress" in params && params.replyAddress !== undefined) {
|
|
1044
|
+
// A UUID names a Claude session as surely as a name does: recognize it
|
|
1045
|
+
// here, or a Claude-to-Claude send addressed by UUID falls through to the
|
|
1046
|
+
// Codex path and is answered as an unknown target.
|
|
1047
|
+
const claudeTarget = target?.binding.provider === "claude" ||
|
|
1048
|
+
(target === undefined && [...this.candidates.values()].some((row) => row.alias === params.toAlias || row.routeHandle === params.toAlias));
|
|
1049
|
+
return claudeTarget ? { accepted: false, code: "route_mismatch" } : this.sendToCodex(params);
|
|
1050
|
+
}
|
|
1051
|
+
if ("threadId" in params && params.threadId !== undefined) {
|
|
1052
|
+
return target?.binding.provider === "codex" ? { accepted: false, code: "route_mismatch" } : this.sendToClaude(params);
|
|
1053
|
+
}
|
|
1054
|
+
return target === undefined || target.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
|
|
930
1055
|
}
|
|
931
1056
|
async sendToClaude(params) {
|
|
932
1057
|
try {
|
|
@@ -939,9 +1064,25 @@ export class GatewayService {
|
|
|
939
1064
|
if (source === undefined)
|
|
940
1065
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
|
|
941
1066
|
const direct = await this.store.inspectPrivateRoute(params.toAlias);
|
|
942
|
-
|
|
943
|
-
if (
|
|
944
|
-
|
|
1067
|
+
let target;
|
|
1068
|
+
if (direct?.binding.provider === "peer") {
|
|
1069
|
+
target = direct;
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const seen = await this.lookUpClaudeRoute(params.toAlias);
|
|
1073
|
+
// Admission before materialization: a send that is already going to be
|
|
1074
|
+
// refused for its own reasons — too large, outside the deadline
|
|
1075
|
+
// window, rate limited, or into a full queue — must not install a
|
|
1076
|
+
// route or displace another session on its way to that refusal.
|
|
1077
|
+
await this.store.assertEnqueueAdmissible({
|
|
1078
|
+
sourceAlias: params.fromAlias,
|
|
1079
|
+
targetAlias: this.claimedAlias(seen),
|
|
1080
|
+
direction: directionId(source.binding.provider, "claude"),
|
|
1081
|
+
sourceRegistrationId: source.binding.registrationId,
|
|
1082
|
+
body: params.text,
|
|
1083
|
+
});
|
|
1084
|
+
target = await this.materializeClaudeRoute(seen);
|
|
1085
|
+
}
|
|
945
1086
|
return await this.enqueueConversation({
|
|
946
1087
|
sourceAlias: params.fromAlias,
|
|
947
1088
|
targetAlias: target.alias,
|
|
@@ -949,13 +1090,10 @@ export class GatewayService {
|
|
|
949
1090
|
expectsReply: params.expectsReply,
|
|
950
1091
|
expectedSourceBinding: source.binding,
|
|
951
1092
|
expectedTargetBinding: target.binding,
|
|
952
|
-
...(params.trackIdleMinutes === undefined
|
|
953
|
-
? {}
|
|
954
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
955
1093
|
});
|
|
956
1094
|
}
|
|
957
1095
|
catch (error) {
|
|
958
|
-
return
|
|
1096
|
+
return sendRefusal(error, "peerToken" in params);
|
|
959
1097
|
}
|
|
960
1098
|
}
|
|
961
1099
|
async sendToCodex(params) {
|
|
@@ -969,13 +1107,18 @@ export class GatewayService {
|
|
|
969
1107
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
970
1108
|
}
|
|
971
1109
|
else {
|
|
1110
|
+
// The sending session's identity comes from its inherited socket, never
|
|
1111
|
+
// from --from. Look the session up read-only first: a send that is
|
|
1112
|
+
// about to be refused must not install, rename, or displace a route,
|
|
1113
|
+
// and must not settle another session's queued work.
|
|
972
1114
|
const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
|
|
973
1115
|
if (resolved === undefined)
|
|
974
1116
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1117
|
+
const seen = await this.lookUpClaudeRoute(resolved.routeHandle, aliasHost(params.fromAlias));
|
|
1118
|
+
if (this.claimedAlias(seen) !== params.fromAlias) {
|
|
1119
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The --from alias does not name the sending Claude session.");
|
|
1120
|
+
}
|
|
1121
|
+
source = await this.materializeClaudeRoute(seen);
|
|
979
1122
|
}
|
|
980
1123
|
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
981
1124
|
if (source === undefined || target === undefined) {
|
|
@@ -991,23 +1134,26 @@ export class GatewayService {
|
|
|
991
1134
|
expectsReply: params.expectsReply,
|
|
992
1135
|
expectedSourceBinding: source.binding,
|
|
993
1136
|
expectedTargetBinding: target.binding,
|
|
994
|
-
...(params.trackIdleMinutes === undefined
|
|
995
|
-
? {}
|
|
996
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
997
1137
|
});
|
|
998
1138
|
}
|
|
999
1139
|
catch (error) {
|
|
1000
|
-
return
|
|
1140
|
+
return sendRefusal(error, "peerToken" in params);
|
|
1001
1141
|
}
|
|
1002
1142
|
}
|
|
1003
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* A send addressed by conversation token: the caller must own one end, the
|
|
1145
|
+
* other end is the binding the conversation recorded, and the caller's
|
|
1146
|
+
* principal is the same attested one every send carries.
|
|
1147
|
+
*/
|
|
1148
|
+
async replyToConversation(params) {
|
|
1004
1149
|
try {
|
|
1005
1150
|
this.assertWritable();
|
|
1006
1151
|
const conversation = this.conversations.get(params.conversationId);
|
|
1007
1152
|
if (conversation === undefined)
|
|
1008
1153
|
throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1154
|
+
const caller = replyCaller(params);
|
|
1155
|
+
const callerBinding = await this.assertReplyCaller(conversation, caller);
|
|
1156
|
+
const callerAlias = caller.alias;
|
|
1011
1157
|
const targetAlias = callerAlias === conversation.sourceAlias
|
|
1012
1158
|
? conversation.targetAlias
|
|
1013
1159
|
: conversation.sourceAlias;
|
|
@@ -1017,27 +1163,9 @@ export class GatewayService {
|
|
|
1017
1163
|
if (targetBinding === undefined) {
|
|
1018
1164
|
throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
|
|
1019
1165
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const enqueued = await this.enqueueNativeConversationReply({
|
|
1024
|
-
conversation,
|
|
1025
|
-
sourceAlias: callerAlias,
|
|
1026
|
-
sourceBinding: callerBinding,
|
|
1027
|
-
target: conversation.nativeTarget,
|
|
1028
|
-
text: params.text,
|
|
1029
|
-
exposeDeliveryToken: true,
|
|
1030
|
-
});
|
|
1031
|
-
if (enqueued.deliveryToken === undefined) {
|
|
1032
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1033
|
-
}
|
|
1034
|
-
return {
|
|
1035
|
-
accepted: true,
|
|
1036
|
-
code: "ok",
|
|
1037
|
-
conversationId: conversation.id,
|
|
1038
|
-
deliveryToken: enqueued.deliveryToken,
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1166
|
+
// The target's route was installed when it first sent or was first
|
|
1167
|
+
// sent to; a reply never re-resolves it by alias. A retired binding is
|
|
1168
|
+
// a refusal at enqueue, never a silent retarget.
|
|
1041
1169
|
return await this.enqueueConversation({
|
|
1042
1170
|
sourceAlias: callerAlias,
|
|
1043
1171
|
targetAlias,
|
|
@@ -1045,14 +1173,11 @@ export class GatewayService {
|
|
|
1045
1173
|
expectsReply: true,
|
|
1046
1174
|
expectedSourceBinding: callerBinding,
|
|
1047
1175
|
expectedTargetBinding: targetBinding,
|
|
1048
|
-
...(params.trackIdleMinutes === undefined
|
|
1049
|
-
? {}
|
|
1050
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
1051
1176
|
existingConversation: conversation,
|
|
1052
1177
|
});
|
|
1053
1178
|
}
|
|
1054
1179
|
catch (error) {
|
|
1055
|
-
return
|
|
1180
|
+
return sendRefusal(error, params.peerToken !== undefined);
|
|
1056
1181
|
}
|
|
1057
1182
|
}
|
|
1058
1183
|
async assertReplyCaller(conversation, caller) {
|
|
@@ -1071,7 +1196,7 @@ export class GatewayService {
|
|
|
1071
1196
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
|
|
1072
1197
|
}
|
|
1073
1198
|
if (caller.kind === "claude") {
|
|
1074
|
-
if (
|
|
1199
|
+
if (route.binding.provider !== "claude") {
|
|
1075
1200
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
1076
1201
|
}
|
|
1077
1202
|
const resolved = await this.claudeAdapter(route.binding.hostId)
|
|
@@ -1117,14 +1242,8 @@ export class GatewayService {
|
|
|
1117
1242
|
...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
|
|
1118
1243
|
...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
|
|
1119
1244
|
expectsReply: input.expectsReply,
|
|
1120
|
-
pair: true,
|
|
1121
1245
|
nextSequence: 0,
|
|
1122
1246
|
};
|
|
1123
|
-
const previousWatch = this.progressWatches.get(conversation.id);
|
|
1124
|
-
const previousWatchEvents = this.progressWatchEvents.length;
|
|
1125
|
-
if (input.skipWatch !== true) {
|
|
1126
|
-
await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
|
|
1127
|
-
}
|
|
1128
1247
|
const sequence = conversation.nextSequence++;
|
|
1129
1248
|
let enqueued;
|
|
1130
1249
|
try {
|
|
@@ -1145,12 +1264,10 @@ export class GatewayService {
|
|
|
1145
1264
|
});
|
|
1146
1265
|
}
|
|
1147
1266
|
catch (error) {
|
|
1148
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1149
1267
|
conversation.nextSequence -= 1;
|
|
1150
1268
|
throw error;
|
|
1151
1269
|
}
|
|
1152
1270
|
if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
|
|
1153
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1154
1271
|
conversation.nextSequence -= 1;
|
|
1155
1272
|
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1156
1273
|
}
|
|
@@ -1173,6 +1290,23 @@ export class GatewayService {
|
|
|
1173
1290
|
deliveryToken: enqueued.deliveryToken,
|
|
1174
1291
|
};
|
|
1175
1292
|
}
|
|
1293
|
+
/**
|
|
1294
|
+
* A renamed Claude session is the same principal under a new display name,
|
|
1295
|
+
* so its open conversations follow it: the registration, not the alias, is
|
|
1296
|
+
* the identity, and a reply under the current name must still resolve.
|
|
1297
|
+
*/
|
|
1298
|
+
renameConversationCoordinates(oldAlias, newAlias, registrationId) {
|
|
1299
|
+
for (const conversation of this.conversations.values()) {
|
|
1300
|
+
if (conversation.sourceAlias === oldAlias &&
|
|
1301
|
+
conversation.sourceBinding?.registrationId === registrationId) {
|
|
1302
|
+
conversation.sourceAlias = newAlias;
|
|
1303
|
+
}
|
|
1304
|
+
if (conversation.targetAlias === oldAlias &&
|
|
1305
|
+
conversation.targetBinding?.registrationId === registrationId) {
|
|
1306
|
+
conversation.targetAlias = newAlias;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1176
1310
|
rememberConversation(conversation) {
|
|
1177
1311
|
this.conversations.delete(conversation.id);
|
|
1178
1312
|
this.conversations.set(conversation.id, conversation);
|
|
@@ -1235,12 +1369,7 @@ export class GatewayService {
|
|
|
1235
1369
|
if (!this.running || this.closing)
|
|
1236
1370
|
return;
|
|
1237
1371
|
const route = await this.store.inspectPrivateRoute(targetAlias);
|
|
1238
|
-
const
|
|
1239
|
-
const transientTarget = transient === undefined
|
|
1240
|
-
? undefined
|
|
1241
|
-
: this.conversations.get(transient.conversationId)?.nativeTarget;
|
|
1242
|
-
const registration = preferredRegistrationId ?? route?.binding.registrationId ??
|
|
1243
|
-
transientTarget?.binding.registrationId;
|
|
1372
|
+
const registration = preferredRegistrationId ?? route?.binding.registrationId;
|
|
1244
1373
|
if (registration === undefined)
|
|
1245
1374
|
return;
|
|
1246
1375
|
this.startRunner(targetAlias, registration, "any");
|
|
@@ -1321,34 +1450,27 @@ export class GatewayService {
|
|
|
1321
1450
|
const conversation = messageContext === undefined
|
|
1322
1451
|
? undefined
|
|
1323
1452
|
: this.conversations.get(messageContext.conversationId);
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
registrationMode: "selected_live_peer",
|
|
1333
|
-
enabled: true,
|
|
1334
|
-
}
|
|
1335
|
-
: (persistedTarget === undefined
|
|
1336
|
-
? undefined
|
|
1337
|
-
: persistedTarget);
|
|
1338
|
-
const source = await this.store.inspectPrivateRoute(attempt.sourceAlias);
|
|
1453
|
+
// Resolve by registration, not by the alias captured at reserve: a
|
|
1454
|
+
// session renamed between reserve and dispatch is the same route and
|
|
1455
|
+
// must still be delivered to, under whatever name it carries now.
|
|
1456
|
+
const installed = await this.store.listLogicalRoutes();
|
|
1457
|
+
const byRegistration = (registration, alias) => installed.find((route) => route.binding.registrationId === registration) ??
|
|
1458
|
+
installed.find((route) => route.alias === alias);
|
|
1459
|
+
const target = byRegistration(attempt.targetRegistrationId, attempt.targetAlias);
|
|
1460
|
+
const source = byRegistration(attempt.sourceRegistrationId, attempt.sourceAlias);
|
|
1339
1461
|
if (this.closing || !this.running) {
|
|
1340
1462
|
await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
|
|
1341
1463
|
return false;
|
|
1342
1464
|
}
|
|
1343
1465
|
if (target === undefined ||
|
|
1344
1466
|
target.binding.registrationId !== attempt.targetRegistrationId ||
|
|
1345
|
-
|
|
1467
|
+
source?.binding.registrationId !== attempt.sourceRegistrationId) {
|
|
1346
1468
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
|
|
1347
1469
|
return false;
|
|
1348
1470
|
}
|
|
1349
1471
|
if (target.registrationMode === "federated_peer") {
|
|
1350
1472
|
const peer = this.peerClients.get(target.binding.hostId);
|
|
1351
|
-
if (peer === undefined || source
|
|
1473
|
+
if (peer === undefined || source.registrationMode === "federated_peer") {
|
|
1352
1474
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1353
1475
|
return false;
|
|
1354
1476
|
}
|
|
@@ -1359,8 +1481,6 @@ export class GatewayService {
|
|
|
1359
1481
|
const params = {
|
|
1360
1482
|
originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
|
|
1361
1483
|
source: sourceEndpoint, target: targetEndpoint,
|
|
1362
|
-
edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
|
|
1363
|
-
edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
|
|
1364
1484
|
deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
|
|
1365
1485
|
body: attempt.body,
|
|
1366
1486
|
...(attempt.steer === true ? { steer: true } : {}),
|
|
@@ -1437,19 +1557,18 @@ export class GatewayService {
|
|
|
1437
1557
|
attemptId: attempt.attemptId,
|
|
1438
1558
|
sourceAlias: attempt.sourceAlias,
|
|
1439
1559
|
sourceProvider: parsed.sourceProvider,
|
|
1440
|
-
|
|
1560
|
+
// The route's CURRENT alias, not the one captured at reserve. The
|
|
1561
|
+
// provider adapters compare this against the alias they hold for the
|
|
1562
|
+
// binding, so a session renamed between reserve and dispatch would
|
|
1563
|
+
// otherwise fail its own delivery over a stale display name.
|
|
1564
|
+
targetAlias: target.alias,
|
|
1441
1565
|
conversationId,
|
|
1442
1566
|
binding: target.binding,
|
|
1443
|
-
authorization:
|
|
1444
|
-
? "selected_route"
|
|
1445
|
-
: "native_reply",
|
|
1567
|
+
authorization: "selected_route",
|
|
1446
1568
|
messageId: attempt.messageId,
|
|
1447
1569
|
text: attempt.body,
|
|
1448
1570
|
expectsReply: conversation?.expectsReply ?? true,
|
|
1449
1571
|
deadlineAt: attempt.deadlineAt,
|
|
1450
|
-
...(this.progressWatches.has(conversationId)
|
|
1451
|
-
? { progressWatchActive: true }
|
|
1452
|
-
: {}),
|
|
1453
1572
|
...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
|
|
1454
1573
|
authorizeWrite: async (evidence) => {
|
|
1455
1574
|
if (evidence.attemptId !== attempt.attemptId)
|
|
@@ -1474,8 +1593,7 @@ export class GatewayService {
|
|
|
1474
1593
|
if (authorized.status === "authorized") {
|
|
1475
1594
|
armed = true;
|
|
1476
1595
|
if (target.binding.provider === "claude" &&
|
|
1477
|
-
messageContext?.expectsReply === true
|
|
1478
|
-
source !== undefined) {
|
|
1596
|
+
messageContext?.expectsReply === true) {
|
|
1479
1597
|
this.installPendingClaudeReply({
|
|
1480
1598
|
messageId: attempt.messageId,
|
|
1481
1599
|
conversationId,
|
|
@@ -1659,7 +1777,6 @@ export class GatewayService {
|
|
|
1659
1777
|
}
|
|
1660
1778
|
}
|
|
1661
1779
|
this.revision += 1;
|
|
1662
|
-
void this.publish().catch(() => undefined);
|
|
1663
1780
|
}
|
|
1664
1781
|
async onClaudeMessage(event) {
|
|
1665
1782
|
const target = await this.store.inspectPrivateRoute(event.targetAlias);
|
|
@@ -1668,21 +1785,23 @@ export class GatewayService {
|
|
|
1668
1785
|
!(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
|
|
1669
1786
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
|
|
1670
1787
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
}
|
|
1788
|
+
// A native SendMessage is this session's send: its route installs here,
|
|
1789
|
+
// from the exact identity the adapter attested, so the Codex task can
|
|
1790
|
+
// reply through the ordinary path. The adapter's name for the sender must
|
|
1791
|
+
// be the name discovery shows for that UUID; the check runs on a read-only
|
|
1792
|
+
// lookup so a refusal writes nothing.
|
|
1793
|
+
this.assertWritable();
|
|
1794
|
+
const seen = await this.lookUpClaudeRoute(event.endpoint.routeHandle, event.endpoint.hostId);
|
|
1795
|
+
if (this.claimedAlias(seen) !== event.sourceAlias) {
|
|
1796
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The native sender's name does not match its discovered session.");
|
|
1797
|
+
}
|
|
1798
|
+
const source = await this.materializeClaudeRoute(seen);
|
|
1799
|
+
const sourceBinding = source.binding;
|
|
1681
1800
|
const conversationId = createGatewayConversationId();
|
|
1682
1801
|
const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
|
|
1683
1802
|
this.assertWritable();
|
|
1684
1803
|
const enqueued = await this.store.enqueueNativeIngress({
|
|
1685
|
-
source: { alias:
|
|
1804
|
+
source: { alias: source.alias, binding: sourceBinding },
|
|
1686
1805
|
targetAlias: event.targetAlias,
|
|
1687
1806
|
expectedTargetRegistrationId: target.binding.registrationId,
|
|
1688
1807
|
body: event.text,
|
|
@@ -1695,13 +1814,11 @@ export class GatewayService {
|
|
|
1695
1814
|
}
|
|
1696
1815
|
const conversation = {
|
|
1697
1816
|
id: conversationId,
|
|
1698
|
-
sourceAlias:
|
|
1817
|
+
sourceAlias: source.alias,
|
|
1699
1818
|
targetAlias: event.targetAlias,
|
|
1700
1819
|
sourceBinding,
|
|
1701
1820
|
targetBinding: target.binding,
|
|
1702
1821
|
expectsReply: true,
|
|
1703
|
-
...(enqueued.pair === true ? { pair: true } : {}),
|
|
1704
|
-
nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
|
|
1705
1822
|
nextSequence: 1,
|
|
1706
1823
|
};
|
|
1707
1824
|
this.rememberConversation(conversation);
|
|
@@ -1806,65 +1923,21 @@ export class GatewayService {
|
|
|
1806
1923
|
const targetBinding = targetAlias === conversation.targetAlias
|
|
1807
1924
|
? conversation.targetBinding
|
|
1808
1925
|
: conversation.sourceBinding;
|
|
1809
|
-
if (
|
|
1810
|
-
sourceAlias !== conversation.nativeTarget.alias) {
|
|
1811
|
-
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1812
|
-
return;
|
|
1813
|
-
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1814
|
-
if (currentSource === undefined ||
|
|
1815
|
-
!sameBinding(currentSource.binding, sourceBinding))
|
|
1816
|
-
return;
|
|
1817
|
-
await this.enqueueConversation({
|
|
1818
|
-
sourceAlias: targetAlias,
|
|
1819
|
-
targetAlias: sourceAlias,
|
|
1820
|
-
text,
|
|
1821
|
-
expectsReply: true,
|
|
1822
|
-
expectedSourceBinding: targetBinding,
|
|
1823
|
-
expectedTargetBinding: sourceBinding,
|
|
1824
|
-
existingConversation: conversation,
|
|
1825
|
-
});
|
|
1926
|
+
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1826
1927
|
return;
|
|
1827
|
-
|
|
1828
|
-
if (
|
|
1928
|
+
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1929
|
+
if (currentSource === undefined ||
|
|
1930
|
+
!sameBinding(currentSource.binding, sourceBinding))
|
|
1829
1931
|
return;
|
|
1830
|
-
await this.
|
|
1831
|
-
conversation,
|
|
1932
|
+
await this.enqueueConversation({
|
|
1832
1933
|
sourceAlias: targetAlias,
|
|
1833
|
-
|
|
1834
|
-
target: conversation.nativeTarget,
|
|
1934
|
+
targetAlias: sourceAlias,
|
|
1835
1935
|
text,
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
async enqueueNativeConversationReply(input) {
|
|
1839
|
-
const sequence = input.conversation.nextSequence++;
|
|
1840
|
-
let enqueued;
|
|
1841
|
-
try {
|
|
1842
|
-
enqueued = await this.store.enqueueNativeReply({
|
|
1843
|
-
sourceAlias: input.sourceAlias,
|
|
1844
|
-
expectedSourceRegistrationId: input.sourceBinding.registrationId,
|
|
1845
|
-
target: input.target,
|
|
1846
|
-
body: input.text,
|
|
1847
|
-
dedupeKey: `${input.conversation.id}:${sequence}`,
|
|
1848
|
-
conversationIdSuffix: input.conversation.id.slice(-8),
|
|
1849
|
-
...(input.conversation.pair === true ? { pair: true } : {}),
|
|
1850
|
-
...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
|
|
1851
|
-
});
|
|
1852
|
-
}
|
|
1853
|
-
catch (error) {
|
|
1854
|
-
input.conversation.nextSequence -= 1;
|
|
1855
|
-
throw error;
|
|
1856
|
-
}
|
|
1857
|
-
if (!enqueued.accepted || enqueued.messageId === undefined) {
|
|
1858
|
-
input.conversation.nextSequence -= 1;
|
|
1859
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1860
|
-
}
|
|
1861
|
-
this.messageContexts.set(enqueued.messageId, {
|
|
1862
|
-
conversationId: input.conversation.id,
|
|
1863
1936
|
expectsReply: true,
|
|
1937
|
+
expectedSourceBinding: targetBinding,
|
|
1938
|
+
expectedTargetBinding: sourceBinding,
|
|
1939
|
+
existingConversation: conversation,
|
|
1864
1940
|
});
|
|
1865
|
-
this.kick(input.target.alias, input.target.binding.registrationId);
|
|
1866
|
-
this.scheduleWake();
|
|
1867
|
-
return enqueued;
|
|
1868
1941
|
}
|
|
1869
1942
|
installPendingClaudeReply(row) {
|
|
1870
1943
|
const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
|
|
@@ -1931,173 +2004,6 @@ export class GatewayService {
|
|
|
1931
2004
|
existingConversation: conversation,
|
|
1932
2005
|
});
|
|
1933
2006
|
}
|
|
1934
|
-
async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
|
|
1935
|
-
const existing = this.progressWatches.get(conversation.id);
|
|
1936
|
-
if (this.config.trackingEnabled === false) {
|
|
1937
|
-
if (existing !== undefined) {
|
|
1938
|
-
this.settleProgressWatch(existing, "gateway", "tracking_disabled");
|
|
1939
|
-
}
|
|
1940
|
-
return;
|
|
1941
|
-
}
|
|
1942
|
-
if (text.startsWith("DONE:")) {
|
|
1943
|
-
if (existing !== undefined) {
|
|
1944
|
-
const actor = actorAlias === existing.ownerAlias
|
|
1945
|
-
? "owner"
|
|
1946
|
-
: actorAlias === existing.workerAlias
|
|
1947
|
-
? "worker"
|
|
1948
|
-
: undefined;
|
|
1949
|
-
if (actor !== undefined)
|
|
1950
|
-
this.settleProgressWatch(existing, actor, "done");
|
|
1951
|
-
}
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1954
|
-
const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
|
|
1955
|
-
if (existing !== undefined && !explicit) {
|
|
1956
|
-
this.progressWatches.set(conversation.id, {
|
|
1957
|
-
...existing,
|
|
1958
|
-
...recordProgressWatchActivity(existing, this.now().getTime()),
|
|
1959
|
-
});
|
|
1960
|
-
return;
|
|
1961
|
-
}
|
|
1962
|
-
if (!explicit)
|
|
1963
|
-
return;
|
|
1964
|
-
if (existing !== undefined && actorAlias !== existing.ownerAlias) {
|
|
1965
|
-
throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
|
|
1966
|
-
}
|
|
1967
|
-
const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
|
|
1968
|
-
if (existing === undefined && this.progressWatches.size >= capacity) {
|
|
1969
|
-
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
|
|
1970
|
-
}
|
|
1971
|
-
const owner = await this.store.inspectPrivateRoute(actorAlias);
|
|
1972
|
-
const workerAlias = actorAlias === conversation.sourceAlias
|
|
1973
|
-
? conversation.targetAlias
|
|
1974
|
-
: conversation.sourceAlias;
|
|
1975
|
-
const worker = await this.store.inspectPrivateRoute(workerAlias);
|
|
1976
|
-
if (owner === undefined || worker === undefined) {
|
|
1977
|
-
throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
|
|
1978
|
-
}
|
|
1979
|
-
const idleMs = trackIdleMinutes === undefined
|
|
1980
|
-
? PROGRESS_WATCH_DEFAULT_IDLE_MS
|
|
1981
|
-
: trackIdleMinutes * 60_000;
|
|
1982
|
-
const watch = createProgressWatch({
|
|
1983
|
-
conversationId: conversation.id,
|
|
1984
|
-
ownerAlias: actorAlias,
|
|
1985
|
-
workerAlias,
|
|
1986
|
-
idleMs,
|
|
1987
|
-
at: this.now().getTime(),
|
|
1988
|
-
});
|
|
1989
|
-
this.progressWatches.set(conversation.id, {
|
|
1990
|
-
...watch,
|
|
1991
|
-
ownerRegistrationId: owner.binding.registrationId,
|
|
1992
|
-
workerRegistrationId: worker.binding.registrationId,
|
|
1993
|
-
});
|
|
1994
|
-
this.recordProgressWatchEvent({
|
|
1995
|
-
watch,
|
|
1996
|
-
kind: existing === undefined ? "opened" : "replaced",
|
|
1997
|
-
actor: "owner",
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
restoreProgressWatch(conversationId, previous, eventLength) {
|
|
2001
|
-
if (previous === undefined)
|
|
2002
|
-
this.progressWatches.delete(conversationId);
|
|
2003
|
-
else
|
|
2004
|
-
this.progressWatches.set(conversationId, previous);
|
|
2005
|
-
this.progressWatchEvents.length = eventLength;
|
|
2006
|
-
}
|
|
2007
|
-
untrack(conversationId) {
|
|
2008
|
-
const watch = this.progressWatches.get(conversationId);
|
|
2009
|
-
if (watch === undefined) {
|
|
2010
|
-
throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
|
|
2011
|
-
}
|
|
2012
|
-
this.settleProgressWatch(watch, "operator", "untracked");
|
|
2013
|
-
}
|
|
2014
|
-
settleWatchesForAlias(alias, reason, actor) {
|
|
2015
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
2016
|
-
if (watch.ownerAlias === alias || watch.workerAlias === alias) {
|
|
2017
|
-
this.settleProgressWatch(watch, actor, reason);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
settleProgressWatch(watch, actor, reason) {
|
|
2022
|
-
if (!this.progressWatches.delete(watch.conversationId))
|
|
2023
|
-
return;
|
|
2024
|
-
this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
|
|
2025
|
-
}
|
|
2026
|
-
recordProgressWatchEvent(input) {
|
|
2027
|
-
this.progressWatchEvents.push({
|
|
2028
|
-
sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
|
|
2029
|
-
timestamp: this.now().toISOString(),
|
|
2030
|
-
conversationIdSuffix: input.watch.conversationId.slice(-8),
|
|
2031
|
-
ownerAlias: input.watch.ownerAlias,
|
|
2032
|
-
workerAlias: input.watch.workerAlias,
|
|
2033
|
-
kind: input.kind,
|
|
2034
|
-
actor: input.actor,
|
|
2035
|
-
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
2036
|
-
});
|
|
2037
|
-
while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
|
|
2038
|
-
this.progressWatchEvents.shift();
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
async processProgressWatches() {
|
|
2042
|
-
const at = this.now().getTime();
|
|
2043
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
2044
|
-
const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
|
|
2045
|
-
const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
|
|
2046
|
-
if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
|
|
2047
|
-
worker?.binding.registrationId !== watch.workerRegistrationId) {
|
|
2048
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2049
|
-
continue;
|
|
2050
|
-
}
|
|
2051
|
-
const ownerObservation = this.routeObservations.get(watch.ownerAlias);
|
|
2052
|
-
const workerObservation = this.routeObservations.get(watch.workerAlias);
|
|
2053
|
-
const due = inspectProgressWatchDue(watch, {
|
|
2054
|
-
at,
|
|
2055
|
-
bothIdle: ownerObservation?.state === "idle" &&
|
|
2056
|
-
workerObservation?.state === "idle" &&
|
|
2057
|
-
this.routeObservationStillCurrent(ownerObservation) &&
|
|
2058
|
-
this.routeObservationStillCurrent(workerObservation),
|
|
2059
|
-
});
|
|
2060
|
-
if (due.kind === "not_due")
|
|
2061
|
-
continue;
|
|
2062
|
-
if (due.kind === "rescheduled") {
|
|
2063
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2064
|
-
...watch,
|
|
2065
|
-
...due.watch,
|
|
2066
|
-
});
|
|
2067
|
-
continue;
|
|
2068
|
-
}
|
|
2069
|
-
if (due.kind === "settled") {
|
|
2070
|
-
this.settleProgressWatch(watch, "gateway", "idle_timeout");
|
|
2071
|
-
continue;
|
|
2072
|
-
}
|
|
2073
|
-
const conversation = this.conversations.get(watch.conversationId);
|
|
2074
|
-
if (conversation === undefined) {
|
|
2075
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
|
|
2079
|
-
try {
|
|
2080
|
-
await this.enqueueConversation({
|
|
2081
|
-
sourceAlias: watch.ownerAlias,
|
|
2082
|
-
targetAlias: watch.workerAlias,
|
|
2083
|
-
text,
|
|
2084
|
-
expectsReply: false,
|
|
2085
|
-
existingConversation: conversation,
|
|
2086
|
-
skipWatch: true,
|
|
2087
|
-
});
|
|
2088
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2089
|
-
...watch,
|
|
2090
|
-
...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
|
|
2091
|
-
});
|
|
2092
|
-
}
|
|
2093
|
-
catch {
|
|
2094
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2095
|
-
...watch,
|
|
2096
|
-
...deferProgressWatchNudge(watch, at),
|
|
2097
|
-
});
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
2007
|
async finishSettlements(settlements) {
|
|
2102
2008
|
await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
|
|
2103
2009
|
}
|
|
@@ -2140,13 +2046,13 @@ export class GatewayService {
|
|
|
2140
2046
|
const now = this.now().getTime();
|
|
2141
2047
|
const due = [
|
|
2142
2048
|
this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
|
|
2143
|
-
this.
|
|
2049
|
+
this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
|
|
2144
2050
|
...(this.config.peerNodes.length > 0
|
|
2145
2051
|
? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
|
|
2146
2052
|
: []),
|
|
2053
|
+
...this.ephemeralPeerExpiries.values(),
|
|
2147
2054
|
...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
|
|
2148
2055
|
...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
|
|
2149
|
-
...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
|
|
2150
2056
|
].filter(Number.isFinite);
|
|
2151
2057
|
const delay = Math.max(0, Math.min(...due) - now);
|
|
2152
2058
|
this.wakeTimer = this.timers.setTimeout(() => {
|
|
@@ -2161,14 +2067,14 @@ export class GatewayService {
|
|
|
2161
2067
|
const now = this.now();
|
|
2162
2068
|
await this.finishSettlements(await this.store.expireDueMessages(now));
|
|
2163
2069
|
this.pruneExpiredPendingClaudeReplies(now.getTime());
|
|
2164
|
-
await this.
|
|
2070
|
+
await this.expireEphemeralPeers(now.getTime());
|
|
2165
2071
|
if (now.getTime() >= this.nextDiscoveryAt) {
|
|
2166
2072
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2167
2073
|
this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
|
|
2168
2074
|
}
|
|
2169
|
-
if (now.getTime() >= this.
|
|
2170
|
-
await this.
|
|
2171
|
-
this.
|
|
2075
|
+
if (now.getTime() >= this.nextManagedCodexCheckAt) {
|
|
2076
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
2077
|
+
this.nextManagedCodexCheckAt = now.getTime() + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
2172
2078
|
}
|
|
2173
2079
|
if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
|
|
2174
2080
|
const operation = this.refreshPeers();
|
|
@@ -2185,7 +2091,6 @@ export class GatewayService {
|
|
|
2185
2091
|
return;
|
|
2186
2092
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
2187
2093
|
this.kick(target);
|
|
2188
|
-
await this.publish();
|
|
2189
2094
|
this.scheduleWake();
|
|
2190
2095
|
}
|
|
2191
2096
|
async refreshPeers() {
|
|
@@ -2249,18 +2154,23 @@ export class GatewayService {
|
|
|
2249
2154
|
if (viewChanged)
|
|
2250
2155
|
this.revision += 1;
|
|
2251
2156
|
}
|
|
2252
|
-
catch {
|
|
2157
|
+
catch (error) {
|
|
2253
2158
|
this.peerClients.get(peerHost)?.close();
|
|
2254
2159
|
this.peerClients.delete(peerHost);
|
|
2255
2160
|
if (!this.running || this.closing)
|
|
2256
2161
|
return;
|
|
2162
|
+
// A wire-version disagreement is not a tunnel fault: name it so an operator
|
|
2163
|
+
// upgrades the lagging node instead of debugging SSH.
|
|
2164
|
+
if (error instanceof PeerConnectionLostError && error.failureClass === "protocol")
|
|
2165
|
+
failureCode = "PEER_PROTOCOL_MISMATCH";
|
|
2257
2166
|
this.recordPeerFailure(peerHost, failureCode);
|
|
2258
2167
|
const observedAt = this.now().toISOString();
|
|
2168
|
+
const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
|
|
2259
2169
|
for (const route of await this.store.inspectPrivateRoutes())
|
|
2260
2170
|
if (route.registrationMode === "federated_peer" &&
|
|
2261
2171
|
route.binding.hostId === peerHost)
|
|
2262
2172
|
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
2263
|
-
state: "unobserved", observedAt, safeErrorCode:
|
|
2173
|
+
state: "unobserved", observedAt, safeErrorCode: mirrorCode });
|
|
2264
2174
|
}
|
|
2265
2175
|
}));
|
|
2266
2176
|
}
|
|
@@ -2323,34 +2233,19 @@ export class GatewayService {
|
|
|
2323
2233
|
for (const alias of retainedCollisions)
|
|
2324
2234
|
this.collidingClaudeAliases.add(alias);
|
|
2325
2235
|
}
|
|
2326
|
-
async
|
|
2327
|
-
if (this.
|
|
2236
|
+
async refreshManagedCodexSocket() {
|
|
2237
|
+
if (this.managedCodexSocketHeld === undefined)
|
|
2328
2238
|
return;
|
|
2329
|
-
this.
|
|
2239
|
+
this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
|
|
2330
2240
|
}
|
|
2331
2241
|
assertWritable() {
|
|
2332
2242
|
if (this.closing || !this.running) {
|
|
2333
2243
|
throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
|
|
2334
2244
|
}
|
|
2335
2245
|
}
|
|
2336
|
-
async resolveClaudeSelector(selector, refresh) {
|
|
2337
|
-
if (refresh)
|
|
2338
|
-
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2339
|
-
if (this.collidingClaudeAliases.has(selector))
|
|
2340
|
-
throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
|
|
2341
|
-
return [...this.candidates.values()].find((candidate) => candidate.routeHandle === selector ||
|
|
2342
|
-
candidate.alias === selector);
|
|
2343
|
-
}
|
|
2344
|
-
async resolveSelectedClaudeRoute(selector) {
|
|
2345
|
-
return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
2346
|
-
(route.alias === selector || route.binding.routeHandle === selector));
|
|
2347
|
-
}
|
|
2348
2246
|
routeObservationStillCurrent(event) {
|
|
2349
2247
|
return this.connectors.has(connectorKey(event.route));
|
|
2350
2248
|
}
|
|
2351
|
-
async publish() {
|
|
2352
|
-
await this.publishDashboard(this.store.rootDir, await this.snapshot());
|
|
2353
|
-
}
|
|
2354
2249
|
alert(code, route, error) {
|
|
2355
2250
|
const errorCode = error instanceof BridgeError ? error.code : undefined;
|
|
2356
2251
|
this.runtimeAlerts.push({
|
|
@@ -2369,7 +2264,7 @@ export class GatewayService {
|
|
|
2369
2264
|
recordPeerFailure(host, code) {
|
|
2370
2265
|
for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
|
|
2371
2266
|
const alert = this.runtimeAlerts[index];
|
|
2372
|
-
if (alert?.host === host && (alert.code
|
|
2267
|
+
if (alert?.host === host && PEER_FAILURE_CODES.includes(alert.code))
|
|
2373
2268
|
this.runtimeAlerts.splice(index, 1);
|
|
2374
2269
|
}
|
|
2375
2270
|
if (code !== undefined)
|